apm-agent-python icon indicating copy to clipboard operation
apm-agent-python copied to clipboard

ElasticAPM fails to process the context from authorizer

Open brett-fitz opened this issue 5 months ago • 4 comments

Describe the bug:

[ERROR] AttributeError: 'dict' object has no attribute 'aws_request_id'
Traceback (most recent call last):
  File "/usr/local/lib/python3.12/importlib/__init__.py", line 90, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1387, in _gcd_import
  File "<frozen importlib._bootstrap>", line 1360, in _find_and_load
  File "<frozen importlib._bootstrap>", line 1331, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 935, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 999, in exec_module
  File "<frozen importlib._bootstrap>", line 488, in _call_with_frames_removed
  File "/opt/threatintel/service.py", line 45, in <module>
    @handler
  File "/usr/local/lib/python3.12/site-packages/elasticapm/contrib/serverless/aws.py", line 111, in decorated
    with _lambda_transaction(func, name, client, event, context) as sls:
  File "/usr/local/lib/python3.12/site-packages/elasticapm/contrib/serverless/aws.py", line 257, in __enter__
    self.set_metadata_and_context(cold_start)
  File "/usr/local/lib/python3.12/site-packages/elasticapm/contrib/serverless/aws.py", line 313, in set_metadata_and_context
    faas["execution"] = self.context.aws_request_id

Interesting enough, when disabling Elastic APM (commenting out), the function runs successfully and the context does have a valid aws_request_id.

Context: LambdaContext([aws_request_id=056c5ea1-0f46-44e3-bd86-09743a79bafc,log_group_name=/aws/lambda/<REDACTED>,log_stream_name=2025/06/06/[$LATEST]<REDACTED>function_name=<REDACTED>memory_limit_in_mb=256,function_version=$LATEST,invoked_function_arn=arn:aws:lambda:us-east-1:<REDACTED>:function:<REDACTED>,client_context=None,identity=CognitoIdentity([cognito_identity_id=None,cognito_identity_pool_id=None]),tenant_id=None])

To Reproduce

  1. Create a AWS Lambda function to be used as an AWS API Gateway Authorizer
  2. Instrument with Elastic APM using capture_serverless() at entrypoint (handler)
  3. Submit a request to your API Gateway that would invoke the authorizer
  4. Observe error due to context missing value

Environment (please complete the following information)

  • OS: [e.g. Linux] Debian (Docker image: python:3.12-slim)
  • Python version: 3.12
  • Framework and version [e.g. Django 2.1]: N/A
  • APM Server version: 8.18.2
  • Agent version: 6.23.0, Lambda extension: 1.5.8

Additional context

Add any other context about the problem here.

  • Agent config options

    Click to expand
    replace this line with your agent config options
    remember to mask any sensitive fields like tokens
    
  • requirements.txt:

    Click to expand
    replace this line with your `requirements.txt`
    

brett-fitz avatar Jun 06 '25 15:06 brett-fitz

Thanks for reporting, [ERROR] AttributeError: 'dict' object has no attribute 'aws_request_id' kinda suggest that self.context["aws_request_id"] would have worked? Or maybe that's just the internals of the LambdaContext class

xrmx avatar Jun 06 '25 15:06 xrmx

@xrmx Yea I think we could just add an type (isinstance) check and then access accordingly?

brett-fitz avatar Jun 06 '25 17:06 brett-fitz

Happy to submit a code update for this too but wanted to validate the bug first.

brett-fitz avatar Jun 06 '25 17:06 brett-fitz

@xrmx Yea I think we could just add an type (isinstance) check and then access accordingly?

Don't know yet, need to reproduce and sort out why LambdaContext is not what we expect

xrmx avatar Jun 08 '25 08:06 xrmx

Hey @xrmx any update here? Happy to help in any way I can!

brett-fitz avatar Sep 03 '25 14:09 brett-fitz

@brett-fitz what kind of authorizer do you have? Can't reproduce with a lambda request authorizer, context is the following:

<class 'awslambdaric.lambda_context.LambdaContext'> ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_epoch_deadline_time_in_ms', 'aws_request_id', 'client_context', 'function_name', 'function_version', 'get_remaining_time_in_millis', 'identity', 'invoked_function_arn', 'log', 'log_group_name', 'log_stream_name', 'memory_limit_in_mb', 'tenant_id'] 

xrmx avatar Sep 26 '25 13:09 xrmx

Hey @xrmx I can share our authorizer code here since its pretty simple. Thank you again for taking a look at this issue and let me know if you need anything else!

api-authorizer/service.py

from __future__ import annotations
from functools import lru_cache
import logging
from os import environ
import ipaddress


from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import event_source
from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import (
    APIGatewayAuthorizerRequestEvent,
    APIGatewayAuthorizerResponse,
)
from aws_lambda_powertools.utilities.typing import LambdaContext

from threatintel.utils.environment import ENVIRONMENT


if ENVIRONMENT.context.runtime != "aws":
    environ["ELASTIC_APM_ENABLED"] = "false"
    environ["AWS_LAMBDA_FUNCTION_NAME"] = "ti-api-authorizer"


logger = Logger(level=logging.INFO)


@lru_cache(maxsize=1)
def get_allowed_ips() -> list[str]:
    """Get list of allowed IPs and subnets from environment variable."""
    allowed_ips = environ.get("ALLOWED_IPS", "")
    return [ip.strip() for ip in allowed_ips.split(",") if ip.strip()]


def is_ip_allowed(ip: str, allowed_subnets: list[str]) -> bool:
    """Check if an IP address is within any of the allowed subnets."""
    try:
        ip_obj = ipaddress.ip_address(ip)
        for subnet in allowed_subnets:
            if ip_obj in ipaddress.ip_network(subnet):
                return True
        return False
    except ValueError as e:
        logger.error(f"Invalid IP address or subnet: {e}")
        return False


def generate_policy(principal_id: str, effect: str, resource: str) -> APIGatewayAuthorizerResponse:
    """Generate an IAM policy document."""
    return {
        "principalId": principal_id,
        "policyDocument": {
            "Version": "2012-10-17",
            "Statement": [{"Action": "execute-api:Invoke", "Effect": effect, "Resource": resource}],
        },
    }


@logger.inject_lambda_context()
@event_source(data_class=APIGatewayAuthorizerRequestEvent)
def authorizer(
    event: APIGatewayAuthorizerRequestEvent, _context: LambdaContext
) -> APIGatewayAuthorizerResponse:
    """Lambda synchronous handler for API Gateway authorizer.

    Checks if the request IP is in the allowed IPs list.
    """
    logger.info(f"Event: {event} Context: {_context}")

    # Get source IP from the event
    source_ip = event.request_context.get("identity", {}).get("sourceIp")
    if not source_ip:
        logger.error("No source IP found in the request")
        return generate_policy("unauthorized", "Deny", event.method_arn)

    allowed_ips = get_allowed_ips()
    if not allowed_ips:
        logger.warning("No allowed IPs found in the environment")
        return generate_policy("unauthorized", "Deny", event.method_arn)

    # Check if IP is allowed
    if is_ip_allowed(source_ip, allowed_ips):
        logger.info(f"IP {source_ip} is authorized")
        return generate_policy(source_ip, "Allow", event.method_arn)

    logger.warning(f"IP {source_ip} is not authorized, allowed subnets: {allowed_ips}")
    return generate_policy("unauthorized", "Deny", event.method_arn)

brett-fitz avatar Oct 01 '25 16:10 brett-fitz

@brett-fitz In the original description you mentioned using capture_serverless decorator but I don't see it here, where do you have it? Have you tried removing one powertools decorator at time to see if that makes a difference?

xrmx avatar Oct 02 '25 08:10 xrmx

@xrmx sorry I removed that out because of this issue 😅. Here is the missing code:

I have another file that contains a handler / decorator that all my lambda functions leverage:

import asyncio
from collections.abc import Coroutine
import functools
import logging
from typing import Callable, TypeVar

from aws_lambda_powertools.utilities.typing import LambdaContext
from elasticapm import capture_serverless

__all__ = [
    "handler",
]

EventT = TypeVar("EventT")
ReturnT = TypeVar("ReturnT")

@capture_serverless()
def handler(
    fn: Callable[[EventT, LambdaContext], Coroutine[None, None, ReturnT]]
) -> Callable[[EventT, LambdaContext], ReturnT]:
    """Decorator to allow async Lambda handlers.

    Wraps an async function so it can be used as a Lambda entrypoint.
    Calls `asyncio.run` on the coroutine.

    Note: `asyncio.run` cannot be called from a running event loop.
    This is safe in AWS Lambda, but may raise an error in other environments.
    """
    @functools.wraps(fn)
    def wrapper(event: EventT, context: LambdaContext) -> ReturnT:
        logging.basicConfig(
            level=logging.INFO,
            format='%(asctime)s : %(levelname)s : %(name)s : %(funcName)s : %(message)s',
            datefmt='%Y-%m-%d %I:%M:%S %p',
            force=True
        )
        if asyncio.iscoroutinefunction(fn):
            return asyncio.run(fn(event, context))
        return fn(event, context)

    return wrapper

In the previous code, when I want to instrument with elasticapm, I would add the decorator to the authorizer function:

@handler
@logger.inject_lambda_context()
@event_source(data_class=APIGatewayAuthorizerRequestEvent)
def authorizer(
    event: APIGatewayAuthorizerRequestEvent, _context: LambdaContext
) -> APIGatewayAuthorizerResponse:
    """Lambda synchronous handler for API Gateway authorizer.

    Checks if the request IP is in the allowed IPs list.
    """
    ...

brett-fitz avatar Oct 02 '25 15:10 brett-fitz

Note: ElasticAPM works on all my other lambdas through this implementation, only issue is the authorizer lambda.

brett-fitz avatar Oct 02 '25 15:10 brett-fitz

@brett-fitz From my testing there is nothing special about the lambda being used as an authorizer, so my guess is there should be something else poking with the context parameter. What's the difference in these decorators between the other lambdas?

xrmx avatar Oct 03 '25 08:10 xrmx

Hey @xrmx I'm going to re-enable this today and test to see if I can catch the exception again and the context around it. There's no difference in the exception procedure between the lambdas except for the input to the entrypoint.

brett-fitz avatar Oct 07 '25 13:10 brett-fitz

@xrmx After testing the capture_serverless decorator at the entrypoint of the authorizer instead of my general decorator/entrypoint, handler, it is now working. There's gotta be something wrong with my decorator. I am going to close this issue out, my apologies.

brett-fitz avatar Oct 14 '25 14:10 brett-fitz

@brett-fitz np, thanks for checking!

xrmx avatar Oct 14 '25 15:10 xrmx