Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Support Micronaut and Spring Cloud Function (and more?) #1701

Open
jeromevdl opened this issue Jul 29, 2024 · 5 comments
Open

RFC: Support Micronaut and Spring Cloud Function (and more?) #1701

jeromevdl opened this issue Jul 29, 2024 · 5 comments
Labels
enhancement New feature or request RFC v2 Version 2

Comments

@jeromevdl
Copy link
Contributor

jeromevdl commented Jul 29, 2024

Key information

  • RFC PR: (leave this empty)
  • Related issue(s), if known:
  • Area: Logger, Tracer, Metrics
  • Meet tenets: Yes

Summary

Allow Spring Cloud Function and Micronaut users to leverage Powertools, as they don't use the standard lambda programming model (RequestHandler / handleRequest())

Motivation

When using Spring Cloud Function and Micronaut, users don't implement RequestHandler or RequestStreamHandler and don't implement the handleRequest method. Spring Cloud Function leverages java.util.function.Function and Micronaut provides MicronautRequestHandler or MicronautRequestStreamHandler. Thus, users cannot use the @Logging, @Tracing and @Metrics annotations from Powertools which specifically apply on the handleRequest method.

We want to allow users to leverage Powertools when using one of these (common) frameworks, thus we need to adapt Powertools.

Current state

LambdaHandlerProcessor from the common module is used to verify if annotations are placedOnRequestHandler or placedOnStreamHandler. We test these to retrieve information from event (event itself, correlation id) in logging.

Concretely, we don't really need to be on a handler method to perform any of the powertools core features:

  • In Logging, isHandlerMethod is used for log sampling, but we don't really need to be on a handler.
  • In Metrics, isHandlerMethod is used to limit the annotation on handlers, but we could open it.
  • In Tracing, isHandlerMethod is used to add some annotations, that's ok if we are not in handler, we don't add them.

We leverage the extractContext() method to get Lambda context in Logging and Metrics

Proposal

  • We can continue to check if we are in a handler to retrieve the event or context, but we should not use it to block a feature (like in Metrics).
  • We need to be able to extract event & context when using Spring Cloud Function and Micronaut:

Spring Cloud Function

This framework leverages java.util.function.Function with the FunctionInvoker. Users need to create a Function and implement the apply() method which takes the event as parameter and return the response. Context is not directly available. To get the context, users can use the Message class from Spring:

public class MyFunction implements Function<Message<APIGatewayProxyRequestEvent>, APIGatewayProxyResponseEvent> {
    @Override
    public APIGatewayProxyResponseEvent apply(Message<APIGatewayProxyRequestEvent> message) {
        Context context = message.getHeaders().get(AWSLambdaUtils.AWS_CONTEXT, Context.class);
        APIGatewayProxyRequestEvent event = message.getPayload();
        // ...
    }
}

➡️ we can get the event with message payload
➡️ we can get the context with message headers. Requires the developer to actually use Message...

Micronaut

With Micronaut, users need to extend MicronautRequestHandler or MicronautRequestStreamHandler and override the execute() method which takes the event as parameter and return the response. Context is not directly accessible but can be injected:

public class MyHandler extends MicronautRequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
    @Any 
    BeanProvider<Context> context;

    @Override
    public APIGatewayProxyResponseEvent execute(APIGatewayProxyRequestEvent requestEvent) {
    }

    @NonNull
    @Override
    protected ApplicationContextBuilder newApplicationContextBuilder() {
        return new LambdaApplicationContextBuilder();
    }
}

➡️ we can get the event with the execute() method's 1st parameter
➡️ context is a bit harder to get as it's not in the execute method but as a field in the class. Requires the developer to actually inject it...

There are other options I didn't analyse yet:

Potential solutions

Solution 1: Using ServiceLoader and granular methods

In the common module, LambdaHandlerProcessor should use a ServiceLoader to load a service that will implement the following interface:

public interface HandlerProcessor {

    /**
     * Determine if this handler can process the given join point.
     * @param pjp
     * @return true if this handler can process the given join point, false otherwise.
     */
    boolean accept(final ProceedingJoinPoint pjp);

    /**
     * Determine if this join point is a handler method:
     * - lambda <code>handleRequest</code>,
     * - Spring cloud <code>apply</code> function,
     * - micronaut <code>execute</code> function,
     * - ...
     * This method is called only if {@link #accept(ProceedingJoinPoint)} returns true.
     * @param pjp
     * @return true if this join point is a handler method (or equivalent), false otherwise.
     */
    boolean isHandlerMethod(final ProceedingJoinPoint pjp);

    /**
     * Extract the Lambda context from the given join point.
     * This method is called only if {@link #accept(ProceedingJoinPoint)} returns true.
     * @param pjp
     * @return the Lambda context
     */
    Context extractContext(final ProceedingJoinPoint pjp);

    /**
     * Extract the Lambda event from the given join point.
     * This method is called only if {@link #accept(ProceedingJoinPoint)} returns true.
     * @param pjp
     * @return the Lambda event
     */
    Object extractEvent(final ProceedingJoinPoint pjp);
}

We'll need to add small libraries for Spring / Micronaut users to add their implementation.
The LambdaHandlerProcessor will iterate on available implementations to find one that accept this kind of Object, and use that one to retrieve the information.

Advantages:

  • With this option we keep the logic in only one class (the aspect), and we just delegate the "lambda stuff" to the service.
  • It's extensible, we can easily add other frameworks by adding a new service/implementation of this interface.

Drawbacks:

  • ServiceLoader can add a bit of latency at initialisation of the function (cold start).
  • We add significant amount of code for frameworks that people might not use.
  • We might need to restructure the code to avoid looping against available services for each method in the LambdaHandlerProcessor...

Solution 2: Using ServiceLoader and global process method

In the common module, LambdaHandlerProcessor should use a ServiceLoader to load a service that will implement the following interface:

public interface HandlerProcessor {
    /**
     * Determine if this handler can process the given join point.
     * @param pjp
     * @return true if this handler can process the given join point, false otherwise.
     */
    boolean accept(final ProceedingJoinPoint pjp);

    /**
     * Process the handler adding the required feature (logging, tracing, metrics)
     * @param pjp
     * @return true if this handler can process the given join point, false otherwise.
     */
    Object process(final ProceedingJoinPoint pjp) throws Throwable;
}

We'll need to add small libraries for Spring / Micronaut users to add their implementation.
With this structure, the majority of the code in the aspect will move to the implementation of this processor in the process method.

Advantages:

  • It's extensible, we can easily add other frameworks by adding a new service/implementation of this interface.
  • It's flexible, it permits to potentially operate differently when in a framework vs Lambda standard.
  • Looks simpler than the solution 1.

Drawbacks:

  • Potentially duplicate code between the diverse implementations
  • ServiceLoader can add a bit of latency at initialisation of the function (cold start).
  • We add significant amount of code for frameworks that people might not use.

Solution 3: Use different pointcuts in the aspect

We can add pointcuts for each ways of handling lambda function:

  • Spring Cloud Function: @Around("execution(@Logging * *.apply(..)) && this(java.util.function.Function)")
  • Lambda standard handler: @Around("execution(@Logging * *.handleRequest(..)) && this(com.amazonaws.services.lambda.runtime.RequestHandler)")
    ... (we'd need to test the best pointcuts)

Advantages:

  • We don't increase the latency too much, just adding a bit of code, no ServiceLoader

Drawbacks:

  • The Aspects will grow quite a lot
  • It does not fully solve the initial problem, we still need some code to retrieve context and event.
  • Less flexible/extensible, we need to add pointcuts and code in the aspect if we want to add other frameworks

Rationale and alternatives

To reduce some of the drawbacks of these 3 solutions, we can probably use a factory and simply check for class presence to get the right implementation:

public class LambdaHandlerFactory {
    public static LambdaHandlerProcessor getProcessor(Object handler) {
        if (handler instanceof RequestHandler || handler instanceof RequestStreamHandler) {
            return new StandardLambdaProcessor();
        }
        if (isClassPresent("org.springframework.cloud.function.adapter.aws.SpringBootRequestHandler")
                && handler instanceof Function) {
            return new SpringCloudFunctionProcessor();
        }
        if (isClassPresent("io.micronaut.function.aws.MicronautRequestHandler")
                && handler.getClass().getSuperclass().getSimpleName().startsWith("MicronautRequest")) {
            return new MicronautProcessor();
        }
        throw new IllegalArgumentException("No processor found for handler: " + handler.getClass());
    }

    private static boolean isClassPresent(String className) {
        try {
            Class.forName(className, false, this.getClass().getClassLoader());
            return true;
        } catch (ClassNotFoundException e) {
            return false;
        }
    }
}

Advantages:

  • This can solve the ServiceLoader problem.
  • It can also help extracting code from the Aspect and moving it to the processors.

Drawbacks:

  • It does not solve the extensibility problem, we'll need to add a if in here to add potential other frameworks.
@maschnetwork
Copy link

Great summary and analysis. I would split this problem into two parts 1) Identifying the handler 2) Identifying the Context.

  1. Identifying the handler

In general the identification of the handler method has also been a challenge for other tools such as the Lambda SnapStart Spotbugs Plugin which tried to come up with all sorts of different ways one could define a handler in different frameworks such as Function, MicronautRequestHandler or Quarkus Funqy with Funq annotation.

I don't think a static approach (mapping Framework to ways it allows to implement handlers) will work in the long run because technically one does not have to follow any interfaces to implement the handler. For example, it is totally valid to do this:

public class UnicornRequestIdHandler {
    public String handleRequest(Map<String, Object> input, Context context) {
        var requestId = context.getAwsRequestId();
        return "Received request with id: %s".formatted(requestId);
    }
}

Working backwards from this simplest way to implement a handler - how are we able to identify this as a handler? Just from pure code analysis or static mapping we can't - however maybe we could introduce an annotation that lets users identify themselves that this is a handler?

public class UnicornRequestIdHandler {

   @LambdaHandler
    public String handleRequest(Map<String, Object> input, Context context) {
        var requestId = context.getAwsRequestId();
        return "Received request with id: %s".formatted(requestId);
    }
}

Similar to the Quarkus Funq annoation. Or in the case of Powertools - Couldn't we intrinsically identify methods that I annotate with @logging, @Tracing and @metrics as a handler? as it wouldn't make much sense to annotate anything else with them.

  1. Identifying the Context

Technically, one could even remove the Context object in the previous example. Starting from this - how would logging or any other module that relies on the context work? Does it make sense to have a fallback mechanisms that works without the context in the worst case? I think you have gathered all relevant ways on how the context can be retrieved (Context Object as seperate input argument to the handler, wrapper of input event with Message<> or injected like Micronaut/Quarkus. Again, would it make sense to automatically catch the most prominent cases (Input argument, message wrapper) but if we can't figure it out provide an explicit Context annotation (Especially for the injected ones)?

Spoiler: I'm not deep into the Powertools implementation and therefore not aware if the above thoughts/proposals would be easily achievable but they should rather be a basis for discussion.

@jeromevdl
Copy link
Contributor Author

Thanks @maschnetwork for the feedback, it's great to have another vision of the problem, and to actually put things in perspective (for example the fact we don't need to implement any interface). Since the beginning, Powertools was based on this postulate that a function necessarily implements RequestHandler or RequestStreamHandler, and has the Context...

The thing we're trying to achieve with this RFC is to start opening Powertools (other frameworks on top of Lambda first, maybe even more later). Assuming that @Logging, @Tracing & @Metrics are obviously on handler would be wrong. By the way, @Tracing can be placed on any method today. And this is what this RFC is meant to: letting users put these annotation on the Spring Cloud Function apply method or the Micronaut execute one. And eventually, anywhere else. On this case obviously, we would not be able to retrieve the context & event.

Also one of our tenet is to simplify developer's life.

➡️ I think we should continue to assume that standard handlers implement RequestHandler or RequestStreamHandler (it's the recommendation from the doc anyway), so that we don't force standard functions to add another annotation.
➡️ Maybe, we could use this @LambdaHandler for non standard ways (Spring, Micronaut or others). This is more flexible than having pointcuts on specific classes/methods like I proposed initially, but it does not help knowing where is the event or context...

@maschnetwork
Copy link

Good callout that it's not possible to actually infer that Tracing, Logging annotations etc. are always placed on a handler. Then the @LamdaHandler might be more appropriate.

That being said and for completeness: It is not given that Spring Cloud Function implementation will always be a class that implements Functional interface and therefore apply. It can be any Functional bean:

@Bean
public Function<String, String> uppercase() {
  return value -> value.toUpperCase();
}

This again shows that there is hardly any standard pattern you can derive the handler method from I'm afraid.

Regarding context:

For completeness afaik you can do also this with Spring Cloud Functions instead of the Message<> wrapper:

@Autowired
private Context context;

Would it make sense to first try to infer it from the handler methods input arguments and provide a second variant for injected contexts? (Maybe even @LambdaContext)?

@jeromevdl
Copy link
Contributor Author

I tried to autowire the context but looks like the bean doesn't exist:

Field context in helloworld.HelloWorld required a bean of type 'com.amazonaws.services.lambda.runtime.Context' that could not be found.

The injection point has the following annotations:
  - @org.springframework.beans.factory.annotation.Autowired(required=true)

Action:
Consider defining a bean of type 'com.amazonaws.services.lambda.runtime.Context' in your configuration.",

Regarding the Function, to add on what you say, Spring Cloud also support Supplier and Consumer (https://github.com/spring-cloud/spring-cloud-function/tree/main/spring-cloud-function-adapters/spring-cloud-function-adapter-aws). Consumer can also take a Message to get event and context. Obviously Supplier can not.

@maschnetwork
Copy link

Looked up my example and saw this was in a (now deprecated) older Spring Cloud Function version that leveraged SpringBootRequestHandler and seems not to be available anymore (See here and here). I don't think we need to cover this case anymore.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request RFC v2 Version 2
Projects
Development

No branches or pull requests

2 participants