diff --git a/sentry_sdk/integrations/aws_lambda/_handler.py b/sentry_sdk/integrations/aws_lambda/_handler.py index cf91faa84b..d4f8350eac 100644 --- a/sentry_sdk/integrations/aws_lambda/_handler.py +++ b/sentry_sdk/integrations/aws_lambda/_handler.py @@ -1,26 +1,26 @@ import functools import sys +from os import environ from typing import TYPE_CHECKING from urllib.parse import urlencode import sentry_sdk from sentry_sdk.api import continue_trace -from sentry_sdk.consts import OP +from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.data_collection import _apply_key_value_collection_filtering from sentry_sdk.integrations._wsgi_common import _filter_headers from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration +from sentry_sdk.integrations.aws_lambda._metadata import _get_availability_zone from sentry_sdk.integrations.aws_lambda._request import ( _get_user_from_event, _make_request_event_processor, ) from sentry_sdk.integrations.aws_lambda.consts import ( - MILLIS_TO_SECONDS, - ORIGIN, - TIMEOUT_WARNING_BUFFER, -) -from sentry_sdk.integrations.cloud_resource_context import ( CLOUD_PLATFORM, CLOUD_PROVIDER, + IDENTIFIER, + LATEST_FUNCTION_VERSION, + ORIGIN, ) from sentry_sdk.scope import Scope, should_send_default_pii from sentry_sdk.traces import SegmentNameSource @@ -35,10 +35,15 @@ ) if TYPE_CHECKING: - from typing import Any, Callable, TypeVar + from typing import Any, Callable, Mapping, TypeVar + + from sentry_sdk._types import Attributes F = TypeVar("F", bound=Callable[..., Any]) +TIMEOUT_WARNING_BUFFER = 1500 +MILLIS_TO_SECONDS = 1000.0 + def _wrap_handler(handler: "F") -> "F": @functools.wraps(handler) @@ -75,8 +80,6 @@ def sentry_handler( request_data = {} configured_time = aws_context.get_remaining_time_in_millis() - aws_region = aws_context.invoked_function_arn.split(":")[3] - with sentry_sdk.isolation_scope() as scope: timeout_thread = None with capture_internal_exceptions(): @@ -86,7 +89,7 @@ def sentry_handler( request_data, aws_context, configured_time ) ) - scope.set_tag("aws_region", aws_region) + scope.set_tag("aws_region", environ["AWS_REGION"]) if batch_size > 1: scope.set_tag("batch_request", True) scope.set_tag("batch_size", batch_size) @@ -118,33 +121,38 @@ def sentry_handler( headers = {} header_attributes: "dict[str, Any]" = {} - for header, header_value in _filter_headers( - headers, use_annotated_value=False - ).items(): - header_attributes[f"http.request.header.{header.lower()}"] = ( - header_value - ) + filtered_headers: "Mapping[str, Any]" = {} + with capture_internal_exceptions(): + filtered_headers = _filter_headers(headers, use_annotated_value=False) + for header, header_value in filtered_headers.items(): + with capture_internal_exceptions(): + header_attributes[ + f"{SPANDATA.HTTP_REQUEST_HEADER}.{header.lower()}" + ] = header_value additional_attributes: "dict[str, Any]" = {} if "httpMethod" in request_data: - additional_attributes["http.request.method"] = request_data[ + additional_attributes[SPANDATA.HTTP_REQUEST_METHOD] = request_data[ "httpMethod" ] - if "queryStringParameters" in request_data: - qs = request_data["queryStringParameters"] - if qs: - if has_data_collection_enabled(client.options): - filtered_qs = _apply_key_value_collection_filtering( - items=qs, - behaviour=client.options["data_collection"][ - "url_query_params" - ], - ) - if filtered_qs: - additional_attributes["url.query"] = urlencode(filtered_qs) - elif should_send_default_pii(): - additional_attributes["url.query"] = urlencode(qs) + with capture_internal_exceptions(): + if "queryStringParameters" in request_data: + qs = request_data["queryStringParameters"] + if qs: + if has_data_collection_enabled(client.options): + filtered_qs = _apply_key_value_collection_filtering( + items=qs, + behaviour=client.options["data_collection"][ + "url_query_params" + ], + ) + if filtered_qs: + additional_attributes[SPANDATA.URL_QUERY] = urlencode( + filtered_qs + ) + elif should_send_default_pii(): + additional_attributes[SPANDATA.URL_QUERY] = urlencode(qs) if not scope._user: if has_data_collection_enabled(client.options): @@ -165,29 +173,58 @@ def sentry_handler( function_name = aws_context.function_name if has_span_streaming_enabled(client.options): + attributes: "Attributes" = { + SPANDATA.SENTRY_OP: OP.FUNCTION_AWS, + SPANDATA.SENTRY_ORIGIN: ORIGIN, + SPANDATA.SENTRY_KIND: "server", + 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(): + invoked_function_arn = aws_context.invoked_function_arn + function_version = aws_context.function_version + arn_parts = invoked_function_arn.split(":") + resource_id = ":".join(arn_parts[:7]) + if function_version != LATEST_FUNCTION_VERSION: + resource_id = f"{resource_id}:{function_version}" + + attributes.update( + { + SPANDATA.CLOUD_REGION: environ["AWS_REGION"], + SPANDATA.CLOUD_ACCOUNT_ID: arn_parts[4], + SPANDATA.CLOUD_RESOURCE_ID: resource_id, + SPANDATA.FAAS_NAME: environ["AWS_LAMBDA_FUNCTION_NAME"], + SPANDATA.FAAS_VERSION: environ[ + "AWS_LAMBDA_FUNCTION_VERSION" + ], + SPANDATA.FAAS_INVOCATION_ID: aws_context.aws_request_id, + SPANDATA.AWS_LAMBDA_INVOKED_ARN: invoked_function_arn, + SPANDATA.AWS_LOG_GROUP_NAMES: [aws_context.log_group_name], + SPANDATA.AWS_LOG_STREAM_NAMES: [ + aws_context.log_stream_name + ], + } + ) + + if "AWS_LAMBDA_METADATA_API" in environ: + with capture_internal_exceptions(): + availability_zone = _get_availability_zone() + if availability_zone is not None: + attributes[SPANDATA.CLOUD_AVAILABILITY_ZONE] = ( + availability_zone + ) + sentry_sdk.traces.continue_trace(headers) Scope.set_custom_sampling_context(sampling_context) span_ctx = sentry_sdk.traces.start_span( name=function_name, parent_span=None, - attributes={ - "sentry.op": OP.FUNCTION_AWS, - "sentry.origin": ORIGIN, - "sentry.segment.name.source": SegmentNameSource.COMPONENT, - "cloud.region": aws_region, - "cloud.resource_id": aws_context.invoked_function_arn, - "cloud.platform": CLOUD_PLATFORM.AWS_LAMBDA, - "cloud.provider": CLOUD_PROVIDER.AWS, - "faas.name": function_name, - "faas.invocation_id": aws_context.aws_request_id, - "faas.version": aws_context.function_version, - "aws.lambda.invoked_arn": aws_context.invoked_function_arn, - "aws.log.group.names": [aws_context.log_group_name], - "aws.log.stream.names": [aws_context.log_stream_name], - "messaging.batch.message_count": batch_size, - **header_attributes, - **additional_attributes, - }, + attributes=attributes, ) else: transaction = continue_trace( @@ -210,7 +247,7 @@ def sentry_handler( sentry_event, hint = event_from_exception( exc_info, client_options=client.options, - mechanism={"type": "aws_lambda", "handled": False}, + mechanism={"type": IDENTIFIER, "handled": False}, ) sentry_sdk.capture_event(sentry_event, hint=hint) reraise(*exc_info) diff --git a/sentry_sdk/integrations/aws_lambda/_metadata.py b/sentry_sdk/integrations/aws_lambda/_metadata.py new file mode 100644 index 0000000000..eab7b49a13 --- /dev/null +++ b/sentry_sdk/integrations/aws_lambda/_metadata.py @@ -0,0 +1,45 @@ +import json +import time +from os import environ +from typing import TYPE_CHECKING + +import urllib3 + +from sentry_sdk.integrations.aws_lambda.consts import ( + LAMBDA_METADATA_PATH, +) + +if TYPE_CHECKING: + from typing import Optional, Tuple + +_lambda_metadata_http = urllib3.PoolManager( + timeout=urllib3.Timeout(connect=0.5, read=0.5), + retries=False, +) +# (`expires_at`, `AvailabilityZoneID`), e.g. (1717987200.0, "use1-az1") +_lambda_metadata_cache: "Optional[Tuple[float, str]]" = None + + +def _get_availability_zone() -> "Optional[str]": + now = time.time() + global _lambda_metadata_cache + if _lambda_metadata_cache is not None and _lambda_metadata_cache[0] > now: + return _lambda_metadata_cache[1] + + # bearer token is required to prevent SSRF. + response = _lambda_metadata_http.request( + "GET", + f"http://{environ['AWS_LAMBDA_METADATA_API']}{LAMBDA_METADATA_PATH}", + headers={"Authorization": f"Bearer {environ['AWS_LAMBDA_METADATA_TOKEN']}"}, + ) + if response.status != 200: + return None + + availability_zone = json.loads(response.data.decode("utf-8"))["AvailabilityZoneID"] + + # AWS shortens `max-age` during SnapStart initialization so restored + # environments refresh `AvailabilityZoneID` for their new AZ. + # https://docs.aws.amazon.com/lambda/latest/dg/configuration-metadata-endpoint.html + max_age = response.headers["Cache-Control"].split("max-age=", 1)[1].split(",", 1)[0] + _lambda_metadata_cache = (now + float(max_age), availability_zone) + return availability_zone diff --git a/sentry_sdk/integrations/aws_lambda/_request.py b/sentry_sdk/integrations/aws_lambda/_request.py index a6ae87ba93..f1eaa48623 100644 --- a/sentry_sdk/integrations/aws_lambda/_request.py +++ b/sentry_sdk/integrations/aws_lambda/_request.py @@ -7,10 +7,7 @@ from sentry_sdk.data_collection import _apply_key_value_collection_filtering from sentry_sdk.integrations._wsgi_common import _filter_headers from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.utils import ( - AnnotatedValue, - has_data_collection_enabled, -) +from sentry_sdk.utils import AnnotatedValue, has_data_collection_enabled if TYPE_CHECKING: from typing import Any, Optional @@ -135,8 +132,8 @@ def _get_url(aws_event: "Any", aws_context: "Any") -> str: host = headers.get("Host", None) proto = headers.get("X-Forwarded-Proto", None) if proto and host and path: - return "{}://{}{}".format(proto, host, path) - return "awslambda:///{}".format(aws_context.function_name) + return f"{proto}://{host}{path}" + return f"awslambda:///{aws_context.function_name}" def _get_cloudwatch_logs_url(aws_context: "Any", start_time: "datetime") -> str: diff --git a/sentry_sdk/integrations/aws_lambda/_runtime.py b/sentry_sdk/integrations/aws_lambda/_runtime.py index 5b4e0f9a3a..52f1e0c35b 100644 --- a/sentry_sdk/integrations/aws_lambda/_runtime.py +++ b/sentry_sdk/integrations/aws_lambda/_runtime.py @@ -4,8 +4,8 @@ from typing import TYPE_CHECKING import sentry_sdk -from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration from sentry_sdk.integrations.aws_lambda._handler import _wrap_handler +from sentry_sdk.integrations.aws_lambda.consts import IDENTIFIER from sentry_sdk.utils import ( capture_internal_exceptions, ensure_integration_enabled, @@ -22,6 +22,8 @@ def _wrap_init_error(init_error: "F") -> "F": + from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration + @ensure_integration_enabled(AwsLambdaIntegration, init_error) def sentry_init_error(*args: "Any", **kwargs: "Any") -> "Any": client = sentry_sdk.get_client() @@ -34,7 +36,7 @@ def sentry_init_error(*args: "Any", **kwargs: "Any") -> "Any": sentry_event, hint = event_from_exception( exc_info, client_options=client.options, - mechanism={"type": "aws_lambda", "handled": False}, + mechanism={"type": IDENTIFIER, "handled": False}, ) sentry_sdk.capture_event(sentry_event, hint=hint) @@ -54,7 +56,7 @@ def sentry_init_error(*args: "Any", **kwargs: "Any") -> "Any": def _drain_queue() -> None: with capture_internal_exceptions(): client = sentry_sdk.get_client() - integration = client.get_integration(AwsLambdaIntegration) + integration = client.get_integration(IDENTIFIER) if integration is not None: # Flush out the event queue before AWS kills the # process. @@ -226,7 +228,7 @@ def _event_from_error_json(error_json: "dict[str, Any]") -> "Event": ), }, "mechanism": { - "type": "aws_lambda", + "type": IDENTIFIER, "handled": False, }, } diff --git a/sentry_sdk/integrations/aws_lambda/consts.py b/sentry_sdk/integrations/aws_lambda/consts.py index 21d10bc026..cddb7ee5fa 100644 --- a/sentry_sdk/integrations/aws_lambda/consts.py +++ b/sentry_sdk/integrations/aws_lambda/consts.py @@ -5,6 +5,4 @@ CLOUD_PLATFORM = "aws_lambda" LATEST_FUNCTION_VERSION = "$LATEST" -# buffer time time required to send timeout warning. -TIMEOUT_WARNING_BUFFER = 1500 -MILLIS_TO_SECONDS = 1000.0 +LAMBDA_METADATA_PATH = "/2026-01-15/metadata/execution-environment" diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSpanStreamingDataCollection/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSpanStreamingDataCollection/index.py index add84cc9c2..0e9d5df647 100644 --- a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSpanStreamingDataCollection/index.py +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/BasicOkSpanStreamingDataCollection/index.py @@ -3,6 +3,14 @@ import sentry_sdk from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration +# otherwise SAM will call its built-in metadata endpoint/token. +os.environ["AWS_LAMBDA_METADATA_API"] = os.environ[ + "SENTRY_TEST_AWS_LAMBDA_METADATA_API" +] +os.environ["AWS_LAMBDA_METADATA_TOKEN"] = os.environ[ + "SENTRY_TEST_AWS_LAMBDA_METADATA_TOKEN" +] + sentry_sdk.init( dsn=os.environ.get("SENTRY_DSN"), traces_sample_rate=1.0, diff --git a/tests/integrations/aws_lambda/test_aws_lambda.py b/tests/integrations/aws_lambda/test_aws_lambda.py index 088a59e96d..496fc31da1 100644 --- a/tests/integrations/aws_lambda/test_aws_lambda.py +++ b/tests/integrations/aws_lambda/test_aws_lambda.py @@ -965,12 +965,19 @@ def test_span_streaming_no_error(lambda_client, test_environment): assert _get_span_attr(attrs, SPANDATA.SENTRY_OP) == OP.FUNCTION_AWS assert _get_span_attr(attrs, SPANDATA.SENTRY_ORIGIN) == ORIGIN + assert _get_span_attr(attrs, SPANDATA.SENTRY_KIND) == "server" assert ( _get_span_attr(attrs, SPANDATA.SENTRY_SEGMENT_NAME_SOURCE) == SegmentNameSource.COMPONENT ) assert _get_span_attr(attrs, SPANDATA.CLOUD_PROVIDER) == CLOUD_PROVIDER assert _get_span_attr(attrs, SPANDATA.CLOUD_PLATFORM) == CLOUD_PLATFORM + assert _get_span_attr(attrs, SPANDATA.CLOUD_ACCOUNT_ID) == "012345678912" + assert _get_span_attr(attrs, SPANDATA.CLOUD_REGION) == "us-east-1" + assert ( + _get_span_attr(attrs, SPANDATA.CLOUD_RESOURCE_ID) + == "arn:aws:lambda:us-east-1:012345678912:function:BasicOkSpanStreaming" + ) assert _get_span_attr(attrs, SPANDATA.FAAS_NAME) == "BasicOkSpanStreaming" assert _get_span_attr(attrs, SPANDATA.FAAS_VERSION) == LATEST_FUNCTION_VERSION assert SPANDATA.FAAS_INVOCATION_ID in attrs @@ -1129,6 +1136,8 @@ def test_span_streaming_request_attributes(lambda_client, test_environment): def test_span_streaming_url_query_params_with_data_collection( lambda_client, test_environment ): + server = test_environment["server"] + initial_metadata_requests = server.lambda_metadata_requests payload = { "httpMethod": "GET", "queryStringParameters": { @@ -1143,7 +1152,7 @@ def test_span_streaming_url_query_params_with_data_collection( FunctionName="BasicOkSpanStreamingDataCollection", Payload=json.dumps(payload), ) - span_items = test_environment["server"].span_items + span_items = server.span_items segment_spans = [s for s in span_items if s["is_segment"]] assert len(segment_spans) == 1 @@ -1156,6 +1165,22 @@ def test_span_streaming_url_query_params_with_data_collection( _get_span_attr(attrs, SPANDATA.URL_QUERY) == "page=2&tracking=%5BFiltered%5D&token=%5BFiltered%5D" ) + assert _get_span_attr(attrs, SPANDATA.CLOUD_AVAILABILITY_ZONE) == "use1-az1" + assert server.lambda_metadata_requests == initial_metadata_requests + 1 + + server.clear_envelopes() + lambda_client.invoke( + FunctionName="BasicOkSpanStreamingDataCollection", + Payload=json.dumps(payload), + ) + segment_spans = [s for s in server.span_items if s["is_segment"]] + + assert len(segment_spans) == 1 + assert ( + _get_span_attr(segment_spans[0]["attributes"], SPANDATA.CLOUD_AVAILABILITY_ZONE) + == "use1-az1" + ) + assert server.lambda_metadata_requests == initial_metadata_requests + 1 def test_span_streaming_user_info_with_send_default_pii( diff --git a/tests/integrations/aws_lambda/utils.py b/tests/integrations/aws_lambda/utils.py index 8933d856ed..0af7535a96 100644 --- a/tests/integrations/aws_lambda/utils.py +++ b/tests/integrations/aws_lambda/utils.py @@ -17,14 +17,17 @@ ) from constructs import Construct from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from scripts.build_aws_lambda_layer import DIST_PATH, build_packaged_zip +from sentry_sdk.integrations.aws_lambda.consts import LAMBDA_METADATA_PATH LAMBDA_FUNCTION_DIR = "./tests/integrations/aws_lambda/lambda_functions/" LAMBDA_FUNCTION_WITH_EMBEDDED_SDK_DIR = ( "./tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/" ) LAMBDA_FUNCTION_TIMEOUT = 10 +LAMBDA_METADATA_TOKEN = "test-token" SAM_PORT = 3001 PYTHON_VERSION = f"python{sys.version_info.major}.{sys.version_info.minor}" @@ -145,12 +148,11 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: sdk_path = os.path.join( LAMBDA_FUNCTION_WITH_EMBEDDED_SDK_DIR, lambda_dir, "sentry_sdk" ) - if not os.path.exists(sdk_path): - # Find the Sentry SDK in the current environment - import sentry_sdk as sdk_module + # refresh the embedded SDK so repeated runs cannot use stale source files. + import sentry_sdk as sdk_module - sdk_source = os.path.dirname(sdk_module.__file__) - shutil.copytree(sdk_source, sdk_path) + sdk_source = os.path.dirname(sdk_module.__file__) + shutil.copytree(sdk_source, sdk_path, dirs_exist_ok=True) # Install the requirements of Sentry SDK into the function directory subprocess.check_call( @@ -166,6 +168,22 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: ] ) + environment = {"SENTRY_DSN": dsn} + if lambda_dir == "BasicOkSpanStreamingDataCollection": + # AWS exposes these env. vars when the metadata endpoint + # (`2026-01-15/metadata/execution-environment`) is available. + # We set et them only on the function that exercises `cloud.availability_zone`. + environment.update( + { + "SENTRY_TEST_AWS_LAMBDA_METADATA_API": ( + f"{get_host_ip()}:9999" + ), + "SENTRY_TEST_AWS_LAMBDA_METADATA_TOKEN": ( + LAMBDA_METADATA_TOKEN + ), + } + ) + CfnResource( self, lambda_dir, @@ -179,9 +197,7 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: "Timeout": LAMBDA_FUNCTION_TIMEOUT, "Architectures": [ARCHITECTURE], "Environment": { - "Variables": { - "SENTRY_DSN": dsn, - } + "Variables": environment, }, }, ) @@ -225,6 +241,7 @@ class SentryServerForTesting: def __init__(self, host="0.0.0.0", port=9999, log_level="warning"): self.envelopes = [] self.span_items = [] + self.lambda_metadata_requests = 0 self.host = host self.port = port self.log_level = log_level @@ -272,6 +289,26 @@ async def envelope(request: Request): return {"status": "ok"} + @self.app.get(LAMBDA_METADATA_PATH) + async def lambda_metadata(request: Request): + """ + Mirrors the Lambda execution-environment metadata endpoint + (`2026-01-15/metadata/execution-environment`) used to collect + `AvailabilityZoneID`, including its cache lifetime. + https://docs.aws.amazon.com/lambda/latest/dg/configuration-metadata-endpoint.html + """ + self.lambda_metadata_requests += 1 + + if request.headers.get("Authorization") != ( + f"Bearer {LAMBDA_METADATA_TOKEN}" + ): + return JSONResponse({"message": "Unauthorized"}, status_code=401) + + return JSONResponse( + {"AvailabilityZoneID": "use1-az1"}, + headers={"Cache-Control": "private, max-age=43200, immutable"}, + ) + def run_server(self): uvicorn.run(self.app, host=self.host, port=self.port, log_level=self.log_level)