From 61bf99840f4e1a683d75b9e73ca94bb2ffa00159 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:11:53 +0000 Subject: [PATCH 1/2] feat(aws-lambda): Detect Lambda triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classify the delivered event into faas.trigger: http (API Gateway, function URLs, ALB, CloudFront, AppSync), pubsub (SQS, SNS, Kinesis, Kafka, MQ, SES, EventBridge), datasource (S3, DynamoDB Streams, DocumentDB), timer (EventBridge schedules) or other. The trigger is read from the event envelope, never from its payload. messaging.batch.message_count is now only set for messaging deliveries, with the number of delivered messages. Co-Authored-By: Johannes Daxböck <114897+dingsdax@users.noreply.github.com> --- sentry_sdk/consts.py | 6 + .../integrations/aws_lambda/_handler.py | 16 +- .../integrations/aws_lambda/_trigger.py | 123 +++++++++++++ .../aws_lambda/test_aws_lambda.py | 169 +++++++++++++++++- 4 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 sentry_sdk/integrations/aws_lambda/_trigger.py 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..f22c174cf3 100644 --- a/sentry_sdk/integrations/aws_lambda/_handler.py +++ b/sentry_sdk/integrations/aws_lambda/_handler.py @@ -12,6 +12,10 @@ _get_user_from_event, _make_request_event_processor, ) +from sentry_sdk.integrations.aws_lambda._trigger import ( + _get_messaging_batch_size, + _get_trigger, +) from sentry_sdk.integrations.aws_lambda.consts import ( CLOUD_PLATFORM, CLOUD_PROVIDER, @@ -64,10 +68,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 +162,19 @@ 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) + # HTTP and direct invocations aren't batches, even when the payload is a list. + messaging_batch_size = _get_messaging_batch_size(aws_event) + if messaging_batch_size is not None: + attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] = ( + messaging_batch_size + ) + 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..a43dbe5ccf --- /dev/null +++ b/sentry_sdk/integrations/aws_lambda/_trigger.py @@ -0,0 +1,123 @@ +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", +} + +# event sources that group their messages by topic-partition or queue. +# https://docs.aws.amazon.com/lambda/latest/dg/with-msk.html +# https://docs.aws.amazon.com/lambda/latest/dg/with-mq.html +_GROUPED_MESSAGES_KEYS: "dict[Optional[str], str]" = { + "aws:kafka": "records", + "SelfManagedKafka": "records", + "aws:rmq": "rmqMessagesByQueue", +} + +# 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") + + +def _get_messaging_batch_size(aws_event: "Any") -> "Optional[int]": + """ + The number of messages delivered by a messaging event source, or `None` for any + other event (HTTP, direct invocation, datasource, ...). + """ + if not isinstance(aws_event, dict): + return None + + records = aws_event.get("Records") + if records: + if _get_records_source(records) in ("aws:sqs", "aws:sns"): + return len(records) + return None + + event_source = aws_event.get("eventSource") + if event_source == "aws:mq": + return len(aws_event["messages"]) + + grouped_messages_key = _GROUPED_MESSAGES_KEYS.get(event_source) + if grouped_messages_key is None: + return None + + return sum(len(messages) for messages in aws_event[grouped_messages_key].values()) diff --git a/tests/integrations/aws_lambda/test_aws_lambda.py b/tests/integrations/aws_lambda/test_aws_lambda.py index 4b253f67cf..0487c7a304 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,171 @@ 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,batch_count", + [ + pytest.param( + {"httpMethod": "GET", "path": "/", "requestContext": {"stage": "prod"}}, + "http", + None, + id="api-gateway-rest", + ), + pytest.param( + { + "version": "2.0", + "routeKey": "GET /", + "requestContext": {"http": {"method": "GET"}}, + }, + "http", + None, + id="api-gateway-http", + ), + pytest.param( + {"Records": [{"cf": {"request": {"uri": "/"}}}]}, + "http", + None, + id="cloudfront", + ), + pytest.param(_appsync_event(), "http", None, id="appsync"), + pytest.param( + [_appsync_event(), _appsync_event()], "http", None, id="appsync-batch" + ), + pytest.param( + {"Records": [_sqs_record(), _sqs_record()]}, "pubsub", 2, id="sqs" + ), + pytest.param({"Records": [_sns_record()]}, "pubsub", 1, id="sns"), + pytest.param( + {"Records": [{"eventSource": "aws:kinesis"}]}, + "pubsub", + None, + id="kinesis", + ), + pytest.param( + { + "eventSource": "aws:kafka", + "records": { + "orders-0": [{"offset": 1}, {"offset": 2}], + "orders-1": [{"offset": 1}], + }, + }, + "pubsub", + 3, + id="msk", + ), + pytest.param( + {"eventSource": "SelfManagedKafka", "records": {"orders-0": [{}]}}, + "pubsub", + 1, + id="self-managed-kafka", + ), + pytest.param( + {"eventSource": "aws:mq", "messages": [{}, {}]}, + "pubsub", + 2, + id="activemq", + ), + pytest.param( + { + "eventSource": "aws:rmq", + "rmqMessagesByQueue": {"orders::/": [{}, {}], "alerts::/": [{}]}, + }, + "pubsub", + 3, + id="rabbitmq", + ), + pytest.param( + _eventbridge_event("aws.s3", "Object Created"), + "pubsub", + None, + id="eventbridge", + ), + pytest.param( + _eventbridge_event("aws.events", "Scheduled Event"), + "timer", + None, + id="eventbridge-schedule", + ), + pytest.param( + {"Records": [{"eventSource": "aws:s3"}]}, "datasource", None, id="s3" + ), + pytest.param( + {"Records": [{"eventSource": "aws:dynamodb"}]}, + "datasource", + None, + id="dynamodb-streams", + ), + pytest.param( + {"eventSource": "aws:docdb", "events": [{}]}, + "datasource", + None, + id="documentdb", + ), + pytest.param( + [{"good dog": "Maisey"}, {"good dog": "Charlie"}], + "other", + None, + id="list", + ), + ], +) +def test_trigger(lambda_client, test_environment, aws_event, trigger, batch_count): + 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 + if batch_count is None: + assert SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT not in attrs + else: + assert ( + _get_span_attr(attrs, SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT) == batch_count + ) def test_trace_continuation(lambda_client, test_environment): From f864c510e50b0f5e43fb6e4a849a6eb6071b548c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:57:42 +0000 Subject: [PATCH 2/2] ref(aws-lambda): Don't set messaging.batch.message_count on the Lambda span Keep the count on messaging spans only (boto3 receive and batch-send spans, and queue.process for SQS/SNS triggers), as in the OTel messaging conventions and the JS SDK. This drops the batch counting for Kafka, MQ and RabbitMQ deliveries, and matches #8219 so both PRs change these lines the same way. Co-Authored-By: johdax <114897+dingsdax@users.noreply.github.com> --- .../integrations/aws_lambda/_handler.py | 11 +----- .../integrations/aws_lambda/_trigger.py | 34 ---------------- .../aws_lambda/test_aws_lambda.py | 39 ++++--------------- 3 files changed, 8 insertions(+), 76 deletions(-) diff --git a/sentry_sdk/integrations/aws_lambda/_handler.py b/sentry_sdk/integrations/aws_lambda/_handler.py index f22c174cf3..affc83977d 100644 --- a/sentry_sdk/integrations/aws_lambda/_handler.py +++ b/sentry_sdk/integrations/aws_lambda/_handler.py @@ -12,10 +12,7 @@ _get_user_from_event, _make_request_event_processor, ) -from sentry_sdk.integrations.aws_lambda._trigger import ( - _get_messaging_batch_size, - _get_trigger, -) +from sentry_sdk.integrations.aws_lambda._trigger import _get_trigger from sentry_sdk.integrations.aws_lambda.consts import ( CLOUD_PLATFORM, CLOUD_PROVIDER, @@ -168,12 +165,6 @@ def sentry_handler( with capture_internal_exceptions(): attributes[SPANDATA.FAAS_TRIGGER] = _get_trigger(aws_event) - # HTTP and direct invocations aren't batches, even when the payload is a list. - messaging_batch_size = _get_messaging_batch_size(aws_event) - if messaging_batch_size is not None: - attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] = ( - messaging_batch_size - ) with capture_internal_exceptions(): invoked_function_arn = aws_context.invoked_function_arn diff --git a/sentry_sdk/integrations/aws_lambda/_trigger.py b/sentry_sdk/integrations/aws_lambda/_trigger.py index a43dbe5ccf..1d3d527a87 100644 --- a/sentry_sdk/integrations/aws_lambda/_trigger.py +++ b/sentry_sdk/integrations/aws_lambda/_trigger.py @@ -19,15 +19,6 @@ "aws:docdb": "datasource", } -# event sources that group their messages by topic-partition or queue. -# https://docs.aws.amazon.com/lambda/latest/dg/with-msk.html -# https://docs.aws.amazon.com/lambda/latest/dg/with-mq.html -_GROUPED_MESSAGES_KEYS: "dict[Optional[str], str]" = { - "aws:kafka": "records", - "SelfManagedKafka": "records", - "aws:rmq": "rmqMessagesByQueue", -} - # https://docs.aws.amazon.com/eventbridge/latest/ref/overiew-event-structure.html _EVENTBRIDGE_KEYS = frozenset( ( @@ -96,28 +87,3 @@ def _get_trigger(aws_event: "Any") -> str: return "pubsub" return _TRIGGERS.get(aws_event.get("eventSource"), "other") - - -def _get_messaging_batch_size(aws_event: "Any") -> "Optional[int]": - """ - The number of messages delivered by a messaging event source, or `None` for any - other event (HTTP, direct invocation, datasource, ...). - """ - if not isinstance(aws_event, dict): - return None - - records = aws_event.get("Records") - if records: - if _get_records_source(records) in ("aws:sqs", "aws:sns"): - return len(records) - return None - - event_source = aws_event.get("eventSource") - if event_source == "aws:mq": - return len(aws_event["messages"]) - - grouped_messages_key = _GROUPED_MESSAGES_KEYS.get(event_source) - if grouped_messages_key is None: - return None - - return sum(len(messages) for messages in aws_event[grouped_messages_key].values()) diff --git a/tests/integrations/aws_lambda/test_aws_lambda.py b/tests/integrations/aws_lambda/test_aws_lambda.py index 0487c7a304..d30cd7d403 100644 --- a/tests/integrations/aws_lambda/test_aws_lambda.py +++ b/tests/integrations/aws_lambda/test_aws_lambda.py @@ -853,12 +853,11 @@ def _eventbridge_event(source, detail_type): @pytest.mark.parametrize( - "aws_event,trigger,batch_count", + "aws_event,trigger", [ pytest.param( {"httpMethod": "GET", "path": "/", "requestContext": {"stage": "prod"}}, "http", - None, id="api-gateway-rest", ), pytest.param( @@ -868,27 +867,20 @@ def _eventbridge_event(source, detail_type): "requestContext": {"http": {"method": "GET"}}, }, "http", - None, id="api-gateway-http", ), pytest.param( {"Records": [{"cf": {"request": {"uri": "/"}}}]}, "http", - None, id="cloudfront", ), - pytest.param(_appsync_event(), "http", None, id="appsync"), - pytest.param( - [_appsync_event(), _appsync_event()], "http", None, id="appsync-batch" - ), - pytest.param( - {"Records": [_sqs_record(), _sqs_record()]}, "pubsub", 2, id="sqs" - ), - pytest.param({"Records": [_sns_record()]}, "pubsub", 1, id="sns"), + 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", - None, id="kinesis", ), pytest.param( @@ -900,19 +892,16 @@ def _eventbridge_event(source, detail_type): }, }, "pubsub", - 3, id="msk", ), pytest.param( {"eventSource": "SelfManagedKafka", "records": {"orders-0": [{}]}}, "pubsub", - 1, id="self-managed-kafka", ), pytest.param( {"eventSource": "aws:mq", "messages": [{}, {}]}, "pubsub", - 2, id="activemq", ), pytest.param( @@ -921,45 +910,37 @@ def _eventbridge_event(source, detail_type): "rmqMessagesByQueue": {"orders::/": [{}, {}], "alerts::/": [{}]}, }, "pubsub", - 3, id="rabbitmq", ), pytest.param( _eventbridge_event("aws.s3", "Object Created"), "pubsub", - None, id="eventbridge", ), pytest.param( _eventbridge_event("aws.events", "Scheduled Event"), "timer", - None, id="eventbridge-schedule", ), - pytest.param( - {"Records": [{"eventSource": "aws:s3"}]}, "datasource", None, id="s3" - ), + pytest.param({"Records": [{"eventSource": "aws:s3"}]}, "datasource", id="s3"), pytest.param( {"Records": [{"eventSource": "aws:dynamodb"}]}, "datasource", - None, id="dynamodb-streams", ), pytest.param( {"eventSource": "aws:docdb", "events": [{}]}, "datasource", - None, id="documentdb", ), pytest.param( [{"good dog": "Maisey"}, {"good dog": "Charlie"}], "other", - None, id="list", ), ], ) -def test_trigger(lambda_client, test_environment, aws_event, trigger, batch_count): +def test_trigger(lambda_client, test_environment, aws_event, trigger): lambda_client.invoke( FunctionName="BasicOkSpanStreaming", Payload=json.dumps(aws_event), @@ -970,12 +951,6 @@ def test_trigger(lambda_client, test_environment, aws_event, trigger, batch_coun ] attrs = segment_span["attributes"] assert _get_span_attr(attrs, SPANDATA.FAAS_TRIGGER) == trigger - if batch_count is None: - assert SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT not in attrs - else: - assert ( - _get_span_attr(attrs, SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT) == batch_count - ) def test_trace_continuation(lambda_client, test_environment):