From 70dca2abe81b786685d089e3e7e541290268e317 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Mon, 26 Oct 2026 18:31:21 +0100 Subject: [PATCH 01/15] . --- 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/15] 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/15] . --- 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/15] 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/15] 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/15] 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/15] 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/15] 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 c640799da086e733083e4f808952eb51e95d0eef Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 30 Oct 2026 13:54:10 +0100 Subject: [PATCH 09/15] 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 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 10/15] 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 11/15] 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 e53db104cc2faa582e5777d57032454df7b83ca1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:16:38 +0000 Subject: [PATCH 12/15] fix(boto3): Guard SNS response attributes An SNS Publish response without MessageId (e.g. from a Stubber or an SNS-compatible service) raised KeyError into the caller and left the span open. Wrap the response reads like SQS does, so a bad shape only loses those attributes. Co-Authored-By: Ivana Kellyer <131587164+sentrivana@users.noreply.github.com> --- sentry_sdk/integrations/boto3/_services/sns.py | 16 ++++++++++------ tests/integrations/boto3/test_sns.py | 11 +++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/sns.py b/sentry_sdk/integrations/boto3/_services/sns.py index 9c91c4b300..024fa74a92 100644 --- a/sentry_sdk/integrations/boto3/_services/sns.py +++ b/sentry_sdk/integrations/boto3/_services/sns.py @@ -8,6 +8,7 @@ _get_batch_error_type, ) from sentry_sdk.traces import SpanStatus +from sentry_sdk.utils import capture_internal_exceptions if TYPE_CHECKING: from typing import Any, Optional @@ -104,13 +105,16 @@ def on_success_response( ) -> None: attributes: "Attributes" = {} - if ctx.operation_name == "Publish": - attributes[SPANDATA.MESSAGING_MESSAGE_ID] = response["MessageId"] + # the call already succeeded; an unexpected response shape, e.g. from a + # `Stubber` or an SNS-compatible service, must only lose these attributes. + with capture_internal_exceptions(): + if ctx.operation_name == "Publish" and "MessageId" in response: + attributes[SPANDATA.MESSAGING_MESSAGE_ID] = response["MessageId"] - error_type = _get_batch_error_type(response) - if error_type is not None: - span.status = SpanStatus.ERROR - attributes[SPANDATA.ERROR_TYPE] = error_type + error_type = _get_batch_error_type(response) + if error_type is not None: + span.status = SpanStatus.ERROR + attributes[SPANDATA.ERROR_TYPE] = error_type span.set_attributes(attributes) span.end() diff --git a/tests/integrations/boto3/test_sns.py b/tests/integrations/boto3/test_sns.py index 1d123d2e6b..b4002828b2 100644 --- a/tests/integrations/boto3/test_sns.py +++ b/tests/integrations/boto3/test_sns.py @@ -81,6 +81,17 @@ def test_request_attributes( None, id="topic", ), + pytest.param( + "publish", + {"TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts", "Message": "hi"}, + {}, + "alerts", + "arn:aws:sns:eu-north-1:123456789012:alerts", + None, + None, + None, + id="topic-without-message-id", + ), pytest.param( "publish", { From ae87c8385dbdaa2b041e592a2da5a0306464c9e8 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:21:14 +0000 Subject: [PATCH 13/15] fix(boto3): Guard SNS request attribute reads botocore validates params after our hook, so a malformed TargetArn or PublishBatchRequestEntries raised out of get_request_attributes and the call ran without a span. Guard the topic, batch-count, and ARN reads separately, like SQS, so a bad value only loses its own attributes. If ARN scrubbing fails, drop the ARN attributes instead of sending raw endpoint or subscription ARNs. Co-Authored-By: Ivana Kellyer <131587164+sentrivana@users.noreply.github.com> --- .../integrations/boto3/_services/sns.py | 85 +++++++++++-------- tests/integrations/boto3/test_sns.py | 38 ++++++++- 2 files changed, 85 insertions(+), 38 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/sns.py b/sentry_sdk/integrations/boto3/_services/sns.py index 024fa74a92..fdf2ee90e5 100644 --- a/sentry_sdk/integrations/boto3/_services/sns.py +++ b/sentry_sdk/integrations/boto3/_services/sns.py @@ -44,24 +44,31 @@ def get_span_name( def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": attributes: "Attributes" = {} - # `TargetArn` can be either a topic or a platform endpoint; a topic ARN should - # never contain ":endpoint/". + # botocore validates params after this hook, so a malformed value must only + # lose the attributes read from it, not the span. target_arn = ctx.params.get("TargetArn") 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 - ) + topic_arn = None + with capture_internal_exceptions(): + # `TargetArn` can be either a topic or a platform endpoint; a topic ARN + # should never contain ":endpoint/". + 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"] + if topic_arn: + attributes[SPANDATA.AWS_SNS_TOPIC_ARN] = topic_arn + # `arn:aws:sns:eu-north-1:123456789012:alerts` -> `alerts` + topic_name = topic_arn.split(":")[-1] + attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = topic_name + elif ctx.operation_name == "CreateTopic" and "Name" in ctx.params: + attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = ctx.params["Name"] if ctx.operation_name in _PUBLISH_OPERATIONS: attributes.update( @@ -76,28 +83,32 @@ def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": # 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": - attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] = len( - ctx.params["PublishBatchRequestEntries"] - ) - - # 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",))) + if "PublishBatchRequestEntries" in ctx.params: + with capture_internal_exceptions(): + attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] = len( + ctx.params["PublishBatchRequestEntries"] + ) + + # if scrubbing fails, drop all ARN attributes rather than send a raw + # endpoint or subscription ARN. + with capture_internal_exceptions(): + # 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 def on_success_response( diff --git a/tests/integrations/boto3/test_sns.py b/tests/integrations/boto3/test_sns.py index b4002828b2..7112197094 100644 --- a/tests/integrations/boto3/test_sns.py +++ b/tests/integrations/boto3/test_sns.py @@ -1,5 +1,5 @@ import pytest -from botocore.exceptions import ClientError +from botocore.exceptions import ClientError, ParamValidationError from botocore.stub import Stubber from sentry_sdk.consts import OP, SPANDATA @@ -244,6 +244,42 @@ def call(): assert SPANDATA.MESSAGING_DESTINATION_NAME not in span["attributes"] +@pytest.mark.tests_internal_exceptions +@pytest.mark.parametrize( + "method,params", + [ + pytest.param( + "publish", + {"TargetArn": 123, "Message": "hi"}, + id="non-string-target-arn", + ), + pytest.param( + "publish_batch", + { + "TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts", + "PublishBatchRequestEntries": None, + }, + id="non-list-batch-entries", + ), + ], +) +def test_publish_with_invalid_params(client_factory, capture_items, method, params): + client = client_factory("sns") + + def call(): + with pytest.raises(ParamValidationError): + getattr(client, method)(**params) + + spans = capture_spans_by_op(call, capture_items) + + (span,) = spans[OP.QUEUE_PUBLISH] + assert span["status"] == "error" + attributes = span["attributes"] + assert attributes[SPANDATA.MESSAGING_SYSTEM] == "aws.sns" + assert SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT not in attributes + assert attributes[SPANDATA.ERROR_TYPE] == "botocore.exceptions.ParamValidationError" + + @pytest.mark.parametrize( "method,params,response,filtered_arn", [ From a7efd5577e3d76d5404714629db6a1d9bbaed1a5 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:35:33 +0000 Subject: [PATCH 14/15] test(boto3): Skip SNS invalid-params test when the operation is missing PublishBatch isn't in older botocore models (e.g. boto3 1.16.63), so skip like the other SNS tests do. Co-Authored-By: Ivana Kellyer <131587164+sentrivana@users.noreply.github.com> --- tests/integrations/boto3/test_sns.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integrations/boto3/test_sns.py b/tests/integrations/boto3/test_sns.py index 7112197094..bf1eccf426 100644 --- a/tests/integrations/boto3/test_sns.py +++ b/tests/integrations/boto3/test_sns.py @@ -266,6 +266,8 @@ def call(): def test_publish_with_invalid_params(client_factory, capture_items, method, params): client = client_factory("sns") + require_botocore_model_fields(client, method) + def call(): with pytest.raises(ParamValidationError): getattr(client, method)(**params) From 165dcf398150d12cac92c9fdea7ceb72ffb69813 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:37:54 +0000 Subject: [PATCH 15/15] docs(boto3): Document SNS span op and name changes in migration guide Co-Authored-By: Ivana Kellyer <131587164+sentrivana@users.noreply.github.com> --- MIGRATION_GUIDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 8f6b7e1a69..461e325357 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -47,7 +47,7 @@ Looking to upgrade from Sentry SDK 2.x to 3.x? Here's a comprehensive list of wh - The `RustTracingIntegration` no longer includes the values of tracing fields if `send_default_pii` is enabled. Pass `include_tracing_fields=True` to the integration to include them. - Whether the Dramatiq integration attaches message bodies to events is now controlled by `data_collection`'s `queues` setting instead of `http_bodies`. - The Celery integration no longer sets the status of task spans to error when a task raises a control flow exception (`Retry`, `Ignore`, or `Reject`). -- The boto3 integration now names spans `.` (for example, `S3.GetObject` or `DynamoDB.GetItem`), following OpenTelemetry semantic conventions. Previously, spans were named `aws..` (for example, `aws.s3.GetObject`). Its spans, including the span for reading a streamed response body, now also have the origin `auto.aws.boto3` instead of `auto.http.boto3`. SQS messaging calls are the exception: `SendMessage` and `SendMessageBatch` are named `send `, `ReceiveMessage` is named `receive `, and `DeleteMessage` and `DeleteMessageBatch` are named `settle `. Update any alerts, dashboards, or `ignore_spans` rules that match on the old names or origin. +- The boto3 integration now names spans `.` (for example, `S3.GetObject` or `DynamoDB.GetItem`), following OpenTelemetry semantic conventions. Previously, spans were named `aws..` (for example, `aws.s3.GetObject`). Its spans, including the span for reading a streamed response body, now also have the origin `auto.aws.boto3` instead of `auto.http.boto3`. SQS and SNS messaging calls are the exception: SQS `SendMessage` and `SendMessageBatch` are named `send `, `ReceiveMessage` is named `receive `, and `DeleteMessage` and `DeleteMessageBatch` are named `settle `; SNS `Publish` and `PublishBatch` are named `send `. Update any alerts, dashboards, or `ignore_spans` rules that match on the old names or origin. - Enabling an AI agent integration (LangChain, OpenAI Agents, or Pydantic AI) no longer disables the OpenAI, Anthropic, and Google GenAI integrations. Instead, those integrations don't create chat spans for requests made from within an agent framework's chat span, but still create other spans, such as embedding spans. The LangChain integration no longer instruments embeddings directly; embedding spans are now created by the integration of the underlying client library. - Whether the MCP integration records tool arguments, tool results, and prompt contents is now controlled by `data_collection`'s new `mcp` setting (`{"inputs": bool, "outputs": bool}`, both `True` by default) instead of the `gen_ai` setting. - With `handled_tool_call_exceptions=True` (the default), the Pydantic AI integration now reports an error for every failed tool argument validation attempt, including retries. Previously, only one error was reported per tool call. @@ -77,7 +77,7 @@ Span ops are now a smaller, framework-neutral set. - The Django integration now emits `view.render` instead of `view.response.render`, `view` instead of `view.authenticate`, and `function` instead of `event.django`. - The Django, Starlette, Starlite, and Litestar integrations now emit `middleware` instead of `middleware.django`, `middleware.starlette`, `middleware.starlette.receive`, `middleware.starlette.send`, `middleware.starlite`, `middleware.starlite.receive`, `middleware.starlite.send`, `middleware.litestar`, `middleware.litestar.receive`, and `middleware.litestar.send`. - The ARQ, Celery, Django, Dramatiq, Huey, RQ, and Ray integrations now emit `queue.publish` and `queue.process` instead of `queue.submit.arq`, `queue.task.arq`, `queue.submit.celery`, `queue.task.celery`, `queue.submit.django`, `queue.task.dramatiq`, `queue.submit.huey`, `queue.task.huey`, `queue.task.rq`, `queue.submit.ray`, and `queue.task.ray`. -- The Boto3 integration now emits `rpc` instead of `http.client` for AWS API calls. SQS messaging calls emit `queue.publish` (`SendMessage`, `SendMessageBatch`), `queue.receive` (`ReceiveMessage`), and `queue.settle` (`DeleteMessage`, `DeleteMessageBatch`) instead. +- The Boto3 integration now emits `rpc` instead of `http.client` for AWS API calls. SQS messaging calls emit `queue.publish` (`SendMessage`, `SendMessageBatch`), `queue.receive` (`ReceiveMessage`), and `queue.settle` (`DeleteMessage`, `DeleteMessageBatch`) instead, and SNS `Publish` and `PublishBatch` emit `queue.publish`. #### GenAI span attributes