From 98c3ae3303651d0dd24f8fdec6bb56a383b1568c Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 14 Oct 2026 22:50:11 +0200 Subject: [PATCH 1/4] feat(boto3): Add Secrets Manager extension - set `aws.secretsmanager.secret.arn` from an ARN `SecretId`, replaced by the response `ARN` - set `aws.secretsmanager.secret.name` from the response `Name` --- sentry_sdk/consts.py | 12 +++ .../boto3/_services/secretsmanager.py | 54 +++++++++- .../integrations/boto3/test_secretsmanager.py | 100 +++++++++++++++--- 3 files changed, 148 insertions(+), 18 deletions(-) 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..1397ce37cc 100644 --- a/sentry_sdk/integrations/boto3/_services/secretsmanager.py +++ b/sentry_sdk/integrations/boto3/_services/secretsmanager.py @@ -1,21 +1,65 @@ +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: + # names can't contain ":"; a partial arn has no suffix (`…:secret:db-backup` → `db`). + # https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_CreateSecret.html#SecretsManager-CreateSecret-request-Name + 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"] + 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": + # when returned, the full `ARN` and exact `Name` replace the `SecretId` values. + # failed calls, tagging and `ValidateResourcePolicy` return neither, the + # replication apis no `Name`; there the request's values remain. + 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..3bb943044f 100644 --- a/tests/integrations/boto3/test_secretsmanager.py +++ b/tests/integrations/boto3/test_secretsmanager.py @@ -1,28 +1,102 @@ +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"}]}, {}, - {"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 + 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.HTTP_CLIENT] 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 + assert attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_ARN] == SECRET_ARN + assert attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_NAME] == "db-password" From 77db69238220bb375f4d90cdc89287f0906a7f2d Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 14 Oct 2026 22:56:19 +0200 Subject: [PATCH 2/4] tests(boto3): change `op` to `rpc` --- tests/integrations/boto3/test_secretsmanager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integrations/boto3/test_secretsmanager.py b/tests/integrations/boto3/test_secretsmanager.py index 3bb943044f..972fe72d0a 100644 --- a/tests/integrations/boto3/test_secretsmanager.py +++ b/tests/integrations/boto3/test_secretsmanager.py @@ -92,7 +92,7 @@ def call(): spans = capture_spans_by_op(call, capture_items) - (span,) = spans[OP.HTTP_CLIENT] + (span,) = spans[OP.RPC] attributes = span["attributes"] assert span["status"] == "error" assert attributes[SPANDATA.ERROR_TYPE] == "ResourceNotFoundException" From aa8b26082c8109a7f87d5beb2cfc722d926e8a7f Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 16 Oct 2026 17:45:26 +0200 Subject: [PATCH 3/4] comments and `capture_internal_exceptions()` explanation --- .../boto3/_services/secretsmanager.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/secretsmanager.py b/sentry_sdk/integrations/boto3/_services/secretsmanager.py index 1397ce37cc..fe605ab25f 100644 --- a/sentry_sdk/integrations/boto3/_services/secretsmanager.py +++ b/sentry_sdk/integrations/boto3/_services/secretsmanager.py @@ -21,22 +21,24 @@ def _get_name_from_arn(arn: str) -> str: - # names can't contain ":"; a partial arn has no suffix (`…:secret:db-backup` → `db`). - # https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_CreateSecret.html#SecretsManager-CreateSecret-request-Name + # `…: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(): - # 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"] + # 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",) ) @@ -48,6 +50,7 @@ def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": ) else: attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_NAME] = secret_id + return attributes def get_response_attributes( From b8619b76bc509243c562a065d5353e3729a097fc Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 16 Oct 2026 17:57:24 +0200 Subject: [PATCH 4/4] more comments --- sentry_sdk/integrations/boto3/_services/secretsmanager.py | 5 ++--- tests/integrations/boto3/test_secretsmanager.py | 3 +++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/secretsmanager.py b/sentry_sdk/integrations/boto3/_services/secretsmanager.py index fe605ab25f..48c2393478 100644 --- a/sentry_sdk/integrations/boto3/_services/secretsmanager.py +++ b/sentry_sdk/integrations/boto3/_services/secretsmanager.py @@ -56,9 +56,8 @@ def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": def get_response_attributes( self, ctx: "AwsCallContext", response: "Any" ) -> "Attributes": - # when returned, the full `ARN` and exact `Name` replace the `SecretId` values. - # failed calls, tagging and `ValidateResourcePolicy` return neither, the - # replication apis no `Name`; there the request's values remain. + # 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, ( diff --git a/tests/integrations/boto3/test_secretsmanager.py b/tests/integrations/boto3/test_secretsmanager.py index 972fe72d0a..8204012b5d 100644 --- a/tests/integrations/boto3/test_secretsmanager.py +++ b/tests/integrations/boto3/test_secretsmanager.py @@ -44,6 +44,7 @@ def secretsmanager_client(client_factory): pytest.param( "tag_resource", {"SecretId": SECRET_ARN, "Tags": [{"Key": "team", "Value": "billing"}]}, + # doesn't return `ARN` or `Name`, so comes from request's `SecretId`. {}, SECRET_ARN, "db-password", @@ -72,6 +73,7 @@ def test_secret_attributes( 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) @@ -98,5 +100,6 @@ def call(): assert attributes[SPANDATA.ERROR_TYPE] == "ResourceNotFoundException" assert attributes[SPANDATA.CLOUD_ACCOUNT_ID] == "123456789012" 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"