diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 92482ab4ce..4b768489bf 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -718,6 +718,12 @@ class SPANDATA: Example: "my_function" """ + FAAS_TRIGGER = "faas.trigger" + """ + The type of the trigger on which the function is executed. + Example: "pubsub" + """ + FAAS_VERSION = "faas.version" """ The version of the function that was invoked. diff --git a/sentry_sdk/integrations/aws_lambda/_handler.py b/sentry_sdk/integrations/aws_lambda/_handler.py index 58a31ede42..2178eff6c3 100644 --- a/sentry_sdk/integrations/aws_lambda/_handler.py +++ b/sentry_sdk/integrations/aws_lambda/_handler.py @@ -13,6 +13,7 @@ _get_user_from_event, _make_request_event_processor, ) +from sentry_sdk.integrations.aws_lambda._trigger import _get_trigger from sentry_sdk.integrations.aws_lambda.consts import ( CLOUD_PLATFORM, CLOUD_PROVIDER, @@ -231,6 +232,9 @@ def sentry_handler( **additional_attributes, } + with capture_internal_exceptions(): + attributes[SPANDATA.FAAS_TRIGGER] = _get_trigger(aws_event) + with capture_internal_exceptions(): invoked_function_arn = aws_context.invoked_function_arn function_version = aws_context.function_version diff --git a/sentry_sdk/integrations/aws_lambda/_trigger.py b/sentry_sdk/integrations/aws_lambda/_trigger.py new file mode 100644 index 0000000000..bce42e624c --- /dev/null +++ b/sentry_sdk/integrations/aws_lambda/_trigger.py @@ -0,0 +1,88 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + +# `faas.trigger` of each `eventSource` of `Records` or of a top-level `eventSource`. +# https://docs.aws.amazon.com/lambda/latest/dg/lambda-services.html +_TRIGGERS = { + "aws:sqs": "pubsub", + "aws:sns": "pubsub", + "aws:kinesis": "pubsub", + "aws:ses": "pubsub", + "aws:kafka": "pubsub", + "SelfManagedKafka": "pubsub", + # ActiveMQ: the Lambda docs show `aws:mq`, real deliveries use `aws:amq`. + "aws:mq": "pubsub", + "aws:amq": "pubsub", + "aws:rmq": "pubsub", + "aws:s3": "datasource", + "aws:dynamodb": "datasource", + "aws:docdb": "datasource", +} + +# https://docs.aws.amazon.com/eventbridge/latest/ref/overiew-event-structure.html +_EVENTBRIDGE_KEYS = frozenset( + ( + "version", + "id", + "detail-type", + "source", + "account", + "time", + "region", + "resources", + "detail", + ) +) + + +def _is_appsync_event(aws_event: "Any") -> bool: + # https://docs.aws.amazon.com/appsync/latest/devguide/resolver-context-reference-js.html + return ( + isinstance(aws_event, dict) + and "arguments" in aws_event + and "fieldName" in aws_event.get("info", {}) + ) + + +def _get_trigger(aws_event: "Any") -> str: + """`faas.trigger` from the envelope of the delivered event, never from its payload.""" + if isinstance(aws_event, list): + # an AppSync batch resolver delivers a list of resolver events. + if aws_event and all(_is_appsync_event(event) for event in aws_event): + return "http" + return "other" + + if not isinstance(aws_event, dict): + return "other" + + # API Gateway REST APIs and ALB send `httpMethod`, HTTP APIs and function URLs + # `requestContext.http`. + request_context = aws_event.get("requestContext") + if request_context is not None and ( + "httpMethod" in aws_event or "http" in request_context + ): + return "http" + + if _is_appsync_event(aws_event): + return "http" + + records = aws_event.get("Records") + if records: + # CloudFront and Lambda@Edge records have no `eventSource`. + if "cf" in records[0]: + return "http" + # SNS spells it `EventSource`. + source = records[0].get("eventSource") or records[0].get("EventSource") + return _TRIGGERS.get(source, "other") + + if _EVENTBRIDGE_KEYS <= aws_event.keys(): + if ( + aws_event["source"] == "aws.events" + and aws_event["detail-type"] == "Scheduled Event" + ): + return "timer" + return "pubsub" + + return _TRIGGERS.get(aws_event.get("eventSource", ""), "other") diff --git a/tests/integrations/aws_lambda/test_aws_lambda.py b/tests/integrations/aws_lambda/test_aws_lambda.py index 1a94221f0b..5b63696bc4 100644 --- a/tests/integrations/aws_lambda/test_aws_lambda.py +++ b/tests/integrations/aws_lambda/test_aws_lambda.py @@ -25,6 +25,32 @@ SAM_TEMPLATE_FILE = "sam.template.yaml" SAM_SHUTDOWN_TIMEOUT = 10 +SQS_RECORD = { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "body": "{}", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:012345678912:orders", +} +SNS_RECORD = { + "EventSource": "aws:sns", + "Sns": {"TopicArn": "arn:aws:sns:us-east-1:012345678912:alerts"}, +} +APPSYNC_EVENT = { + "arguments": {"id": "1"}, + "info": {"fieldName": "getOrder", "parentTypeName": "Query"}, +} +EVENTBRIDGE_EVENT = { + "version": "0", + "id": "53dc4d37-cffa-4f76-80c9-8b7d4a4d2eaa", + "detail-type": "Object Created", + "source": "aws.s3", + "account": "012345678912", + "time": "2026-10-26T12:00:00Z", + "region": "us-east-1", + "resources": [], + "detail": {}, +} + @pytest.fixture(scope="session", autouse=True) def test_environment(): @@ -767,6 +793,7 @@ def test_no_error(lambda_client, test_environment): assert _get_span_attr(attrs, SPANDATA.AWS_LOG_STREAM_NAMES) == [ LATEST_FUNCTION_VERSION ] + assert _get_span_attr(attrs, SPANDATA.FAAS_TRIGGER) == "other" def test_error(lambda_client, test_environment): @@ -820,6 +847,73 @@ def test_error(lambda_client, test_environment): assert _get_span_attr(attrs, SPANDATA.AWS_LOG_STREAM_NAMES) == [ LATEST_FUNCTION_VERSION ] + assert _get_span_attr(attrs, SPANDATA.FAAS_TRIGGER) == "other" + + +@pytest.mark.parametrize( + "aws_event,trigger", + [ + pytest.param( + {"httpMethod": "GET", "path": "/", "requestContext": {"stage": "prod"}}, + "http", + id="api-gateway-rest", + ), + pytest.param( + { + "version": "2.0", + "routeKey": "GET /", + "requestContext": {"http": {"method": "GET"}}, + }, + "http", + id="api-gateway-http", + ), + pytest.param( + {"Records": [{"cf": {"request": {"uri": "/"}}}]}, + "http", + id="cloudfront", + ), + pytest.param(APPSYNC_EVENT, "http", id="appsync"), + pytest.param([APPSYNC_EVENT, APPSYNC_EVENT], "http", id="appsync-batch"), + pytest.param({"Records": [SQS_RECORD, SQS_RECORD]}, "pubsub", id="sqs"), + pytest.param({"Records": [SNS_RECORD]}, "pubsub", id="sns"), + pytest.param( + {"eventSource": "aws:kafka", "records": {"orders-0": [{}]}}, + "pubsub", + id="msk", + ), + pytest.param( + EVENTBRIDGE_EVENT, + "pubsub", + id="eventbridge", + ), + pytest.param( + { + **EVENTBRIDGE_EVENT, + "source": "aws.events", + "detail-type": "Scheduled Event", + }, + "timer", + id="eventbridge-schedule", + ), + pytest.param({"Records": [{"eventSource": "aws:s3"}]}, "datasource", id="s3"), + pytest.param( + [{"good dog": "Maisey"}, {"good dog": "Charlie"}], + "other", + id="list", + ), + ], +) +def test_trigger(lambda_client, test_environment, aws_event, trigger): + lambda_client.invoke( + FunctionName="BasicOkSpanStreaming", + Payload=json.dumps(aws_event), + ) + + (segment_span,) = [ + s for s in test_environment["server"].span_items if s["is_segment"] + ] + attrs = segment_span["attributes"] + assert _get_span_attr(attrs, SPANDATA.FAAS_TRIGGER) == trigger def test_trace_continuation(lambda_client, test_environment):