diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 5ff880ab73..1d40d17a65 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -423,6 +423,18 @@ class SPANDATA: Example: "dfRtDYWFbkRONycy.Yxwh66Yjlx.cph0gtNBtJ" """ + AWS_SECRETSMANAGER_SECRET_ARN = "aws.secretsmanager.secret.arn" + """ + The ARN of the Secrets Manager secret the request refers to. + Example: "arn:aws:secretsmanager:us-east-1:123456789012:secret:db-password-AbCdEf" + """ + + AWS_SECRETSMANAGER_SECRET_NAME = "aws.secretsmanager.secret.name" + """ + The name of the Secrets Manager secret the request refers to. + Example: "db-password" + """ + CACHE_HIT = "cache.hit" """ A boolean indicating whether the requested data was found in the cache. diff --git a/sentry_sdk/integrations/boto3/_services/secretsmanager.py b/sentry_sdk/integrations/boto3/_services/secretsmanager.py index f83d415697..48c2393478 100644 --- a/sentry_sdk/integrations/boto3/_services/secretsmanager.py +++ b/sentry_sdk/integrations/boto3/_services/secretsmanager.py @@ -1,21 +1,67 @@ +import re from typing import TYPE_CHECKING +from sentry_sdk.consts import 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 ( + _extract_attributes, + _get_aws_arn_attributes, +) from sentry_sdk.utils import capture_internal_exceptions if TYPE_CHECKING: + from typing import Any + from sentry_sdk._types import Attributes from sentry_sdk.integrations.boto3._context import AwsCallContext +# secret-arn ends with name, a hyphen and six random chars (`…:secret:db-password-AbCdEf`). +# https://docs.aws.amazon.com/secretsmanager/latest/userguide/troubleshoot.html#ARN_secretnamehyphen +_ARN_SUFFIX = re.compile(r"-[A-Za-z0-9]{6}$") + + +def _get_name_from_arn(arn: str) -> str: + # `…:secret:db-backup` => `db` + return _ARN_SUFFIX.sub("", arn.rsplit(":", 1)[1]) + class _SecretsManagerExtension(_ServiceExtension): def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": attributes: "Attributes" = {} - with capture_internal_exceptions(): - attributes.update( - _get_aws_arn_attributes( - (ctx.params.get("SecretId"),), ("secretsmanager",) + + # only `CreateSecret` takes a `Name`; every other operations acts on + # a secret by name or full/partial arn. + if "Name" in ctx.params: + attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_NAME] = ctx.params["Name"] + + if "SecretId" in ctx.params: + secret_id = ctx.params["SecretId"] + # `SecretId` isn't validated yet. `get_request_attributes()` runs before + # botocore validates the params; a malformed one mustn't drop the other attributes. + with capture_internal_exceptions(): + arn_attributes = _get_aws_arn_attributes( + (secret_id,), ("secretsmanager",) ) - ) + attributes.update(arn_attributes) + if arn_attributes: + attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_ARN] = secret_id + attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_NAME] = ( + _get_name_from_arn(secret_id) + ) + else: + attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_NAME] = secret_id + return attributes + + def get_response_attributes( + self, ctx: "AwsCallContext", response: "Any" + ) -> "Attributes": + # the response's full `ARN` and exact `Name` override the request's values, + # which stay when the response has neither, e.g. failed calls. + return _extract_attributes( + response, + ( + ("ARN", SPANDATA.AWS_SECRETSMANAGER_SECRET_ARN), + ("Name", SPANDATA.AWS_SECRETSMANAGER_SECRET_NAME), + ), + ) diff --git a/tests/integrations/boto3/test_secretsmanager.py b/tests/integrations/boto3/test_secretsmanager.py index da0a844e13..8204012b5d 100644 --- a/tests/integrations/boto3/test_secretsmanager.py +++ b/tests/integrations/boto3/test_secretsmanager.py @@ -1,28 +1,105 @@ +import pytest +from botocore.exceptions import ClientError from botocore.stub import Stubber from sentry_sdk.consts import OP, SPANDATA from tests.integrations.boto3.helpers import capture_spans_by_op from tests.integrations.boto3.helpers import client_factory as client_factory +SECRET_ARN = "arn:aws:secretsmanager:eu-north-1:123456789012:secret:db-password-AbCdEf" -def test_request_attributes(client_factory, capture_items): - client = client_factory("secretsmanager") - secret_arn = "arn:aws:secretsmanager:eu-north-1:123456789012:secret:orders-AbCdEf" - with Stubber(client) as stubber: - stubber.add_response( - "delete_secret", +@pytest.fixture +def secretsmanager_client(client_factory): + return client_factory("secretsmanager") + + +@pytest.mark.parametrize( + "method,params,response,expected_arn,expected_name", + [ + pytest.param( + "get_secret_value", + {"SecretId": "db-password"}, + {"ARN": SECRET_ARN, "Name": "db-password", "SecretString": "hunter2"}, + SECRET_ARN, + "db-password", + id="name", + ), + pytest.param( + "get_secret_value", + {"SecretId": SECRET_ARN}, + {"ARN": SECRET_ARN, "Name": "db-password", "SecretString": "hunter2"}, + SECRET_ARN, + "db-password", + id="arn", + ), + pytest.param( + "create_secret", + {"Name": "db-password", "SecretString": "hunter2"}, + {"ARN": SECRET_ARN, "Name": "db-password"}, + SECRET_ARN, + "db-password", + id="create", + ), + pytest.param( + "tag_resource", + {"SecretId": SECRET_ARN, "Tags": [{"Key": "team", "Value": "billing"}]}, + # doesn't return `ARN` or `Name`, so comes from request's `SecretId`. {}, - {"SecretId": secret_arn, "ForceDeleteWithoutRecovery": True}, - ) + SECRET_ARN, + "db-password", + id="arn-without-response", + ), + ], +) +def test_secret_attributes( + secretsmanager_client, + capture_items, + method, + params, + response, + expected_arn, + expected_name, +): + with Stubber(secretsmanager_client) as stubber: + stubber.add_response(method, response, params) spans = capture_spans_by_op( - lambda: client.delete_secret( - **{"SecretId": secret_arn, "ForceDeleteWithoutRecovery": True} - ), - capture_items, + lambda: getattr(secretsmanager_client, method)(**params), capture_items + ) + + (span,) = spans[OP.RPC] + operation = secretsmanager_client.meta.method_to_api_mapping[method] + assert span["name"] == "SecretsManager." + operation + attributes = span["attributes"] + assert attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_ARN] == expected_arn + assert attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_NAME] == expected_name + # secret value is never recorded. + assert "hunter2" not in str(span) + + +def test_client_error(secretsmanager_client, capture_items): + params = {"SecretId": SECRET_ARN} + + with Stubber(secretsmanager_client) as stubber: + stubber.add_client_error( + "get_secret_value", + service_error_code="ResourceNotFoundException", + http_status_code=400, + expected_params=params, ) + def call(): + with pytest.raises(ClientError): + secretsmanager_client.get_secret_value(**params) + + spans = capture_spans_by_op(call, capture_items) + (span,) = spans[OP.RPC] attributes = span["attributes"] + assert span["status"] == "error" + assert attributes[SPANDATA.ERROR_TYPE] == "ResourceNotFoundException" assert attributes[SPANDATA.CLOUD_ACCOUNT_ID] == "123456789012" - assert attributes[SPANDATA.CLOUD_RESOURCE_ID] == secret_arn + assert attributes[SPANDATA.CLOUD_RESOURCE_ID] == SECRET_ARN + # both from the request's `SecretId`. + assert attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_ARN] == SECRET_ARN + assert attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_NAME] == "db-password"