Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion sentry_sdk/integrations/boto3/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
84 changes: 68 additions & 16 deletions sentry_sdk/integrations/boto3/_services/stepfunctions.py
Original file line number Diff line number Diff line change
@@ -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:<partition>:states:<region>:<account>:<type>:<name>…`; 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
157 changes: 142 additions & 15 deletions tests/integrations/boto3/test_stepfunctions.py
Original file line number Diff line number Diff line change
@@ -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)
Loading