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 diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index d40812f293..acd677677f 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -489,6 +489,12 @@ class SPANDATA: 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" """ A boolean indicating whether the requested data was found in the cache. diff --git a/sentry_sdk/integrations/boto3/_services/sns.py b/sentry_sdk/integrations/boto3/_services/sns.py index 4810beb2ce..fdf2ee90e5 100644 --- a/sentry_sdk/integrations/boto3/_services/sns.py +++ b/sentry_sdk/integrations/boto3/_services/sns.py @@ -1,36 +1,131 @@ 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.integrations.boto3._utils import ( + _get_aws_arn_attributes, + _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 + from typing import Any, 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") + 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( + 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]}" + + # 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" = {} + + # 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") + 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` + 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( - _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 PII. + if "PhoneNumber" in ctx.params or endpoint_arn: + attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = "[Filtered]" + 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( self, ctx: "AwsCallContext", response: "Any", span: "Span" ) -> None: + attributes: "Attributes" = {} + + # 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 + + span.set_attributes(attributes) span.end() diff --git a/tests/integrations/boto3/test_sns.py b/tests/integrations/boto3/test_sns.py index 7f593d357e..bf1eccf426 100644 --- a/tests/integrations/boto3/test_sns.py +++ b/tests/integrations/boto3/test_sns.py @@ -1,21 +1,358 @@ +import pytest +from botocore.exceptions import ClientError, ParamValidationError from botocore.stub import Stubber from sentry_sdk.consts import OP, SPANDATA -from tests.integrations.boto3.helpers import capture_spans_by_op +from sentry_sdk.integrations.boto3 import Boto3Integration +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 +_BATCH_ENTRIES = [ + {"Id": "1", "Message": "hi"}, + {"Id": "2", "Message": "hi"}, +] -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.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" + assert SPANDATA.MESSAGING_SYSTEM not in attributes + + +@pytest.mark.parametrize( + "method,params,response,destination,topic_arn,message_count,message_id,error_type", + [ + pytest.param( + "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( + "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", + { + "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( + "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", + "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, + method, + params, + response, + destination, + topic_arn, + message_count, + message_id, + error_type, +): + client = client_factory("sns") + + require_botocore_model_fields(client, method) + + with Stubber(client) as stubber: + stubber.add_response(method, response) + spans = capture_spans_by_op( + 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_OPERATION_TYPE] == "send" + assert attributes[SPANDATA.MESSAGING_OPERATION_NAME] == "publish" + assert attributes.get(SPANDATA.AWS_SNS_TOPIC_ARN) == topic_arn + 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): + 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 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") + + require_botocore_model_fields(client, method) + + 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", + [ + pytest.param( + "get_endpoint_attributes", + { + "EndpointArn": "arn:aws:sns:eu-north-1:123456789012:endpoint/GCM/shop/5f1c8a3e-0b7d-3c55-9b5e-1a2b3c4d5e6f" + }, + {"Attributes": {}}, + "arn:aws:sns:eu-north-1:123456789012:app/GCM/shop", + id="endpoint", + ), + pytest.param( + "unsubscribe", + { + "SubscriptionArn": "arn:aws:sns:eu-north-1:123456789012:alerts:8e2a6b1c-4d3f-4f6a-9c1e-2b3d4e5f6a7b" + }, + {}, + "arn:aws:sns:eu-north-1:123456789012:alerts", + 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"}, + "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"}, + "arn:aws:sns:eu-north-1:123456789012:alerts", + id="topic", + ), + ], +) +@pytest.mark.parametrize( + "data_collection,expect_user_info", DATA_COLLECTION_USER_INFO_CASES +) +def test_resource_id_data_collection( + sentry_init, + client_factory, + capture_items, + method, + params, + response, + filtered_arn, + 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() + attributes = span["attributes"] + assert attributes[SPANDATA.CLOUD_RESOURCE_ID] == ( + arn if expect_user_info else filtered_arn + ) assert attributes[SPANDATA.CLOUD_ACCOUNT_ID] == "123456789012" - assert attributes[SPANDATA.CLOUD_RESOURCE_ID] == topic_arn