diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index f6cb52d45c..a87a4e4243 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -706,6 +706,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 98209b9104..affc83977d 100644 --- a/sentry_sdk/integrations/aws_lambda/_handler.py +++ b/sentry_sdk/integrations/aws_lambda/_handler.py @@ -12,6 +12,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, @@ -64,10 +65,8 @@ def sentry_handler( if isinstance(aws_event, list) and len(aws_event) >= 1: request_data = aws_event[0] - batch_size = len(aws_event) else: request_data = aws_event - batch_size = 1 if not isinstance(request_data, dict): # If we're not dealing with a dictionary, we won't be able to get @@ -160,11 +159,13 @@ def sentry_handler( SPANDATA.SENTRY_SEGMENT_NAME_SOURCE: SegmentNameSource.COMPONENT, SPANDATA.CLOUD_PROVIDER: CLOUD_PROVIDER, SPANDATA.CLOUD_PLATFORM: CLOUD_PLATFORM, - SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT: batch_size, **header_attributes, **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..1d3d527a87 --- /dev/null +++ b/sentry_sdk/integrations/aws_lambda/_trigger.py @@ -0,0 +1,89 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Optional + +# `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: "dict[Optional[str], str]" = { + "aws:sqs": "pubsub", + "aws:sns": "pubsub", + "aws:kinesis": "pubsub", + "aws:ses": "pubsub", + "aws:kafka": "pubsub", + "SelfManagedKafka": "pubsub", + "aws:mq": "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 _get_records_source(records: "list[Any]") -> "Optional[str]": + """The records' `eventSource`, one per event; SNS spells it `EventSource`.""" + return records[0].get("eventSource") or records[0].get("EventSource") + + +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" + return _TRIGGERS.get(_get_records_source(records), "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 4b253f67cf..d30cd7d403 100644 --- a/tests/integrations/aws_lambda/test_aws_lambda.py +++ b/tests/integrations/aws_lambda/test_aws_lambda.py @@ -756,7 +756,8 @@ 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.MESSAGING_BATCH_MESSAGE_COUNT) == 1 + assert _get_span_attr(attrs, SPANDATA.FAAS_TRIGGER) == "other" + assert SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT not in attrs def test_error(lambda_client, test_environment): @@ -810,7 +811,146 @@ 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.MESSAGING_BATCH_MESSAGE_COUNT) == 1 + assert _get_span_attr(attrs, SPANDATA.FAAS_TRIGGER) == "other" + assert SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT not in attrs + + +def _sqs_record(): + return { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "body": "{}", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:012345678912:orders", + } + + +def _sns_record(): + return { + "EventSource": "aws:sns", + "Sns": {"TopicArn": "arn:aws:sns:us-east-1:012345678912:alerts"}, + } + + +def _appsync_event(): + return { + "arguments": {"id": "1"}, + "info": {"fieldName": "getOrder", "parentTypeName": "Query"}, + } + + +def _eventbridge_event(source, detail_type): + return { + "version": "0", + "id": "53dc4d37-cffa-4f76-80c9-8b7d4a4d2eaa", + "detail-type": detail_type, + "source": source, + "account": "012345678912", + "time": "2026-10-26T12:00:00Z", + "region": "us-east-1", + "resources": [], + "detail": {}, + } + + +@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( + {"Records": [{"eventSource": "aws:kinesis"}]}, + "pubsub", + id="kinesis", + ), + pytest.param( + { + "eventSource": "aws:kafka", + "records": { + "orders-0": [{"offset": 1}, {"offset": 2}], + "orders-1": [{"offset": 1}], + }, + }, + "pubsub", + id="msk", + ), + pytest.param( + {"eventSource": "SelfManagedKafka", "records": {"orders-0": [{}]}}, + "pubsub", + id="self-managed-kafka", + ), + pytest.param( + {"eventSource": "aws:mq", "messages": [{}, {}]}, + "pubsub", + id="activemq", + ), + pytest.param( + { + "eventSource": "aws:rmq", + "rmqMessagesByQueue": {"orders::/": [{}, {}], "alerts::/": [{}]}, + }, + "pubsub", + id="rabbitmq", + ), + pytest.param( + _eventbridge_event("aws.s3", "Object Created"), + "pubsub", + id="eventbridge", + ), + pytest.param( + _eventbridge_event("aws.events", "Scheduled Event"), + "timer", + id="eventbridge-schedule", + ), + pytest.param({"Records": [{"eventSource": "aws:s3"}]}, "datasource", id="s3"), + pytest.param( + {"Records": [{"eventSource": "aws:dynamodb"}]}, + "datasource", + id="dynamodb-streams", + ), + pytest.param( + {"eventSource": "aws:docdb", "events": [{}]}, + "datasource", + id="documentdb", + ), + 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):