diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 5ff880ab73..48a98505ec 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -423,6 +423,36 @@ class SPANDATA: Example: "dfRtDYWFbkRONycy.Yxwh66Yjlx.cph0gtNBtJ" """ + AWS_STEP_FUNCTIONS_ACTIVITY_ARN = "aws.step_functions.activity.arn" + """ + The ARN of the AWS Step Functions activity the request refers to. + Example: "arn:aws:states:us-east-1:123456789012:activity:approve-order" + """ + + AWS_STEP_FUNCTIONS_ACTIVITY_NAME = "aws.step_functions.activity.name" + """ + The name of the AWS Step Functions activity the request refers to. + Example: "approve-order" + """ + + AWS_STEP_FUNCTIONS_EXECUTION_ARN = "aws.step_functions.execution.arn" + """ + The ARN of the AWS Step Functions execution the request refers to. + Example: "arn:aws:states:us-east-1:123456789012:execution:orders:4f9b1c2e" + """ + + AWS_STEP_FUNCTIONS_STATE_MACHINE_ARN = "aws.step_functions.state_machine.arn" + """ + The ARN of the AWS Step Functions state machine the request refers to. + Example: "arn:aws:states:us-east-1:123456789012:stateMachine:orders" + """ + + AWS_STEP_FUNCTIONS_STATE_MACHINE_NAME = "aws.step_functions.state_machine.name" + """ + The name of the AWS Step Functions state machine the request refers to, without a version or alias qualifier. + Example: "orders" + """ + CACHE_HIT = "cache.hit" """ A boolean indicating whether the requested data was found in the cache. diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index 10a5bb5c93..35ea846e22 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING import sentry_sdk +from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations import DidNotEnable from sentry_sdk.integrations.boto3._context import AwsCallContext from sentry_sdk.integrations.boto3._instrumentation import ( @@ -17,7 +18,7 @@ _resolve_service, ) from sentry_sdk.integrations.boto3.consts import IDENTIFIER -from sentry_sdk.traces import NoOpSpan, Span +from sentry_sdk.traces import NoOpSpan, Span, SpanStatus from sentry_sdk.utils import capture_internal_exceptions if TYPE_CHECKING: @@ -125,6 +126,10 @@ def sentry_patched_make_api_call( attributes.update(_get_response_attributes(parsed)) with capture_internal_exceptions(): span.set_attributes(attributes) + # a service extension reports a failure that didn't raise, i.e. one + # in a successful response, with `error.type`. + if SPANDATA.ERROR_TYPE in attributes: + span.status = SpanStatus.ERROR except BaseException as error: _finish_span(span, error) raise diff --git a/sentry_sdk/integrations/boto3/_services/stepfunctions.py b/sentry_sdk/integrations/boto3/_services/stepfunctions.py index 2c37397660..8311b5405e 100644 --- a/sentry_sdk/integrations/boto3/_services/stepfunctions.py +++ b/sentry_sdk/integrations/boto3/_services/stepfunctions.py @@ -1,30 +1,82 @@ 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, Sequence + from sentry_sdk._types import Attributes from sentry_sdk.integrations.boto3._context import AwsCallContext + from sentry_sdk.integrations.boto3._utils import _AttributeSpec + +# set on both request and response if available. +_ARN_ATTRIBUTES: "Sequence[_AttributeSpec]" = ( + ("activityArn", SPANDATA.AWS_STEP_FUNCTIONS_ACTIVITY_ARN), + ("executionArn", SPANDATA.AWS_STEP_FUNCTIONS_EXECUTION_ARN), + ("stateMachineArn", SPANDATA.AWS_STEP_FUNCTIONS_STATE_MACHINE_ARN), +) class _StepFunctionsExtension(_ServiceExtension): def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": - attributes: "Attributes" = {} + attributes: "Attributes" = _extract_attributes(ctx.params, _ARN_ATTRIBUTES) + + # elsewhere `name` names an execution or an alias. + if "name" in ctx.params: + name = ctx.params["name"] + if ctx.operation_name == "CreateActivity": + attributes[SPANDATA.AWS_STEP_FUNCTIONS_ACTIVITY_NAME] = name + elif ctx.operation_name == "CreateStateMachine": + attributes[SPANDATA.AWS_STEP_FUNCTIONS_STATE_MACHINE_NAME] = name + + for param in ( + "activityArn", + "executionArn", + "mapRunArn", + "resourceArn", + "stateMachineAliasArn", + "stateMachineArn", + "stateMachineVersionArn", + ): + if param in ctx.params: + arn = ctx.params[param] + break + else: + return attributes + + # params aren't validated yet by botocore. with capture_internal_exceptions(): - attributes.update( - _get_aws_arn_attributes( - ( - ctx.params.get("activityArn"), - ctx.params.get("executionArn"), - ctx.params.get("mapRunArn"), - ctx.params.get("resourceArn"), - ctx.params.get("stateMachineAliasArn"), - ctx.params.get("stateMachineArn"), - ctx.params.get("stateMachineVersionArn"), - ), - ("states",), - ) - ) + arn_attributes = _get_aws_arn_attributes((arn,), ("states",)) + attributes.update(arn_attributes) + if arn_attributes: + # `arn::states::::…`; the name + # ends at the next ":" (version, alias, execution) or "/" (map run). + resource_type, resource = arn.split(":", 6)[5:] + name = resource.split(":", 1)[0].split("/", 1)[0] + if resource_type == "activity": + attributes[SPANDATA.AWS_STEP_FUNCTIONS_ACTIVITY_NAME] = name + else: + attributes[SPANDATA.AWS_STEP_FUNCTIONS_STATE_MACHINE_NAME] = name + + return attributes + + def get_response_attributes( + self, ctx: "AwsCallContext", response: "Any" + ) -> "Attributes": + attributes = _extract_attributes(response, _ARN_ATTRIBUTES) + + # `StartSyncExecution` returns `200 OK` even for failed/timed out executions. + # https://docs.aws.amazon.com/step-functions/latest/apireference/API_StartSyncExecution.html + if ctx.operation_name == "StartSyncExecution": + if response.get("status") == "FAILED": + attributes[SPANDATA.ERROR_TYPE] = "stepfunctions_execution_failed" + elif response.get("status") == "TIMED_OUT": + attributes[SPANDATA.ERROR_TYPE] = "stepfunctions_execution_timed_out" + return attributes diff --git a/tests/integrations/boto3/test_stepfunctions.py b/tests/integrations/boto3/test_stepfunctions.py index e080effc46..f2ebc81eb7 100644 --- a/tests/integrations/boto3/test_stepfunctions.py +++ b/tests/integrations/boto3/test_stepfunctions.py @@ -1,26 +1,153 @@ +from datetime import datetime, timezone + +import pytest 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 ( + capture_spans_by_op, + require_botocore_model_fields, +) from tests.integrations.boto3.helpers import client_factory as client_factory +ARN_PREFIX = "arn:aws:states:eu-north-1:123456789012:" +STATE_MACHINE_ARN = ARN_PREFIX + "stateMachine:orders" +ALIAS_ARN = STATE_MACHINE_ARN + ":prod" +EXECUTION_ARN = ARN_PREFIX + "execution:orders:order-1234" +MAP_RUN_ARN = ARN_PREFIX + "mapRun:orders/process-items:5e7c" +ACTIVITY_ARN = ARN_PREFIX + "activity:approve" +DATE = datetime(2026, 10, 14, tzinfo=timezone.utc) -def test_request_attributes(client_factory, capture_items): - client = client_factory("stepfunctions") - state_machine_arn = "arn:aws:states:eu-north-1:123456789012:stateMachine:orders" +def _stubbed_span(client_factory, capture_items, method, params, response): + client = client_factory("stepfunctions") + require_botocore_model_fields( + client, method, input_fields=tuple(params), output_fields=tuple(response) + ) with Stubber(client) as stubber: - stubber.add_response( - "delete_state_machine", {}, {"stateMachineArn": state_machine_arn} - ) + stubber.add_response(method, response, params) spans = capture_spans_by_op( - lambda: client.delete_state_machine( - **{"stateMachineArn": state_machine_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] == state_machine_arn + return span + + +@pytest.mark.parametrize( + "method,params,response,expected", + [ + pytest.param( + "start_execution", + {"stateMachineArn": ALIAS_ARN, "name": "order-1234"}, + {"executionArn": EXECUTION_ARN, "startDate": DATE}, + { + SPANDATA.CLOUD_RESOURCE_ID: ALIAS_ARN, + SPANDATA.AWS_STEP_FUNCTIONS_STATE_MACHINE_ARN: ALIAS_ARN, + SPANDATA.AWS_STEP_FUNCTIONS_STATE_MACHINE_NAME: "orders", + SPANDATA.AWS_STEP_FUNCTIONS_EXECUTION_ARN: EXECUTION_ARN, + }, + id="alias", + ), + pytest.param( + "stop_execution", + {"executionArn": EXECUTION_ARN}, + {"stopDate": DATE}, + { + SPANDATA.CLOUD_RESOURCE_ID: EXECUTION_ARN, + SPANDATA.AWS_STEP_FUNCTIONS_EXECUTION_ARN: EXECUTION_ARN, + SPANDATA.AWS_STEP_FUNCTIONS_STATE_MACHINE_NAME: "orders", + }, + id="execution", + ), + pytest.param( + "list_executions", + {"mapRunArn": MAP_RUN_ARN}, + {"executions": []}, + { + SPANDATA.CLOUD_RESOURCE_ID: MAP_RUN_ARN, + SPANDATA.AWS_STEP_FUNCTIONS_STATE_MACHINE_NAME: "orders", + }, + id="map-run", + ), + pytest.param( + "create_state_machine", + { + "name": "orders", + "definition": "{}", + "roleArn": "arn:aws:iam::123456789012:role/orders", + }, + {"stateMachineArn": STATE_MACHINE_ARN, "creationDate": DATE}, + { + SPANDATA.AWS_STEP_FUNCTIONS_STATE_MACHINE_ARN: STATE_MACHINE_ARN, + SPANDATA.AWS_STEP_FUNCTIONS_STATE_MACHINE_NAME: "orders", + }, + id="create-state-machine", + ), + pytest.param( + "create_activity", + {"name": "approve"}, + {"activityArn": ACTIVITY_ARN, "creationDate": DATE}, + { + SPANDATA.AWS_STEP_FUNCTIONS_ACTIVITY_ARN: ACTIVITY_ARN, + SPANDATA.AWS_STEP_FUNCTIONS_ACTIVITY_NAME: "approve", + }, + id="create-activity", + ), + pytest.param( + "tag_resource", + {"resourceArn": ACTIVITY_ARN, "tags": [{"key": "team", "value": "a"}]}, + {}, + { + SPANDATA.CLOUD_RESOURCE_ID: ACTIVITY_ARN, + SPANDATA.AWS_STEP_FUNCTIONS_ACTIVITY_NAME: "approve", + }, + id="tag-activity", + ), + ], +) +def test_resource_attributes( + client_factory, capture_items, method, params, response, expected +): + span = _stubbed_span(client_factory, capture_items, method, params, response) + + # `cloud.resource_id` is only set from the arn in the request the call acts on. + assert { + key: value + for key, value in span["attributes"].items() + if key.startswith("aws.step_functions.") or key == SPANDATA.CLOUD_RESOURCE_ID + } == expected + + +@pytest.mark.parametrize( + "method,status,expected_error_type", + [ + ("start_sync_execution", "SUCCEEDED", None), + ("start_sync_execution", "FAILED", "stepfunctions_execution_failed"), + ("start_sync_execution", "TIMED_OUT", "stepfunctions_execution_timed_out"), + ("describe_execution", "FAILED", None), + ], +) +def test_execution_outcome( + client_factory, capture_items, method, status, expected_error_type +): + params = ( + {"stateMachineArn": STATE_MACHINE_ARN, "input": '{"card": "4242"}'} + if method == "start_sync_execution" + else {"executionArn": EXECUTION_ARN} + ) + response = { + "executionArn": EXECUTION_ARN, + "stateMachineArn": STATE_MACHINE_ARN, + "status": status, + "startDate": DATE, + "stopDate": DATE, + "error": "States.TaskFailed", + "cause": "card 4242 declined", + } + span = _stubbed_span(client_factory, capture_items, method, params, response) + + assert span["status"] == ("ok" if expected_error_type is None else "error") + assert span["attributes"].get(SPANDATA.ERROR_TYPE) == expected_error_type + # the execution's input, error and cause are never recorded. + assert "4242" not in str(span) + assert "States.TaskFailed" not in str(span)