NAV

Aegis

An AWS Serverless Framework for Go

Handler Dependencies

Handlers need a variety of services and dependencies in order to make life easier. It’s a very big part of why one reaches for a framework. The idea of “dependency injection” is the foundation of many frameworks and Aegis isn’t much different.

Really, routing events to handlers is the foundation of Aegis, but you can’t abstract that away without being able to pass along functionality required by each handler.

Aegis has a few core dependencies that even its internal handler (a handler before your handler) uses and passes along for you to use as well. Also, you can also pass along your own dependencies if you need to.

Log

logger := logrus.New()
// Various configuration options for logrus...

AegisApp = aegis.New(handlers)
// Set your configured logger on Log
AegisApp.Log = logger
AegisApp.Start()

Aegis’ Log dependency provides an adaptable way to log using the popular Go package logrus. You’re certainly welcome to use the standard library log package as well. AWS Lambda will send all of that stdout to CloudWatch for you automatically.

However, there may be other logging services you wish to use. Rather than re-invent the wheel, Aegis used logrus instead because of its adaptability.

Set the Log field on the Aegis interface like any other core dependency to change it. You can also use the ConfigureLogger(*logrus.Logger) helper function.

Do note that everything internally will use log. So anything that Aegis logs out will be found in CloudWatch. It’s not that Aegis didn’t want to dogfood its own logging dependency, it’s that Aegis did not want its logging to get in the way of your logging.

For example, if your handler (just one of your Lambdas) wanted to use Log to send information to your team Slack channel, you wouldn’t also want internal Aegis logging to be sent as well. Especially because you couldn’t turn them off. There is currently no sort of verbosity setting in Aegis. If you were to, say, disable CloudWatch logging for your Lambda altogether then the standard log calls would simply go no where.

CloudWatch of course isn’t the prettiest way to view your logs. While logrus has fancy coloring, CloudWatch is black and white text through your web browser in the AWS Console. So you might end up configuring Log in a different way to work with logging in a prettier way.

Another thing worth noting here is that logrus’ 3rd party formatters can be rather powerful. There are formatters for things like fluentd, logstash, and more. So you might just find yourself using it for more than simply basic logging. Also keep in mind that AWS has a hosted version of Elasticsearch with Kibana…See where this is going?? You can implement a rather robust searchable logging solution all within AWS.

Tracer

How to use a different TraceStrategy

AegisApp = aegis.New(handlers)
// For example, disabling tracing
AegisApp.Tracer = &aegis.NoTraceStrategy{}
AegisApp.Start()

How Aegis Router traces internally

// Records data in the tracing strategy. In this case, and by default, X-Ray annotations.
r.Tracer.Record("annotation",
    map[string]interface{}{
        "RequestPath": req.Path,
        "Method":      req.HTTPMethod,
    },
)

err = r.Tracer.Capture(ctx, "RouteHandler", func(ctx1 context.Context) error {
    // Makes the Tracer available inside your handler.
    // Capture() also applies the annotations from above in the case of XRayTraceStrategy
    d.Tracer = &r.Tracer
    return handler.handler(ctx1, d, &req, &res, params)
})

The Tracer dependency implements a TraceStrategy interface. By default, this is AWS X-Ray defined as XRayTraceStrategy. There is also NoTraceStrategy available if you’d like to disable tracing (or for unit tests).

However, you can add your own tracing strategy interfaces and use those instead if you wanted to use something other than X-Ray.

Using a different strategy is as simple as setting the Tracer field on the Aegis interface. In the examples, this is usually called AegisApp. Do keep in mind that this is a pointer.

Aegis’ tracing works a little differently than AWS’ X-Ray. Though you are always free to import github.com/aws/aws-xray-sdk-go/xray to use yourself.

Aegis receives all events in one centralized function and then sends them out to the appropriate router which in turn calls the matching handler. This is all internal and while there are a few hooks available, no one wants to use those hooks to run tracing. It’s simply not convenient nor very conventional.

So, Aegis’ XRayTraceStrategy has some fields, namely; Annotation, NamespaceAnnotation, Metadata, and Error. These are maps (except Error, a singular error). So we are simply adding to them with Record() and Capture() will loop through them and add on to the segment in AWS X-Ray. A different interface implementing TraceStrategy may do something completely different. The NoTraceStrategy that Aegis has will simply do nothing.

Every router should add some default annoatations and metadata automatically for you. Whether your trace strategy handles that data is another story. So if you are using X-Ray, you don’t need to add the API Gateway request path or HTTP method. If you run Aegis with all the defaults, you’re just going to get traced API requests with meaningful annotations.

Segments

BeginSegment() and BeginSubsegment() are somewhat universal concepts. AWS X-Ray has the concept of segments and subsegments, but other tracing services may only deal with segments. Those segments may even be implicit so you may find yourself not needing to implement these functions in a custom strategy depending on the service you’re using.

Regardless, all they do is help organize and segment your traces. In the case of X-Ray, Aegis will automatically create an “Aegis” segment for you when running Aegis from the CLI or local web server. When running in AWS Lambda, a segment is created automatically by Lambda for you.

This may not be the case if using a custom trace strategy. You may also want to create subsegments to trace within your handlers. These are the functions to help you out.

CloseSegment() and CloseSubsegment() then help you define the end of those segments. In the case of the default XRayTraceStrategy, closing a subsegment also sends data off to X-Ray. These functions just call X-Ray’s Close() and CloseAndStream(). They’re aliases more or less.

Recording Data

Regardless of the tracing service you ultimately use, the concept of Record() is pretty universal. The idea is that you want to record certain bits of data with your tracing. AWS X-Ray calls these “metadata,” “annotations,” “namespaced annotations,” and a single “error” that can be sent along with each trace.

The XRayTraceStrategy implementation of Record() will simply set some struct fields with the data and nothing will actually be “sent” to AWS X-Ray until Capture() or CaptureAsync() is called.

The reason for this is because we aren’t “streaming” a bunch of data to AWS X-Ray. We’re “reporting” on segments of our application. Your trace strategy may differ and there’s no reason Record() couldn’t send “real-time” data along. It’s just not how X-Ray works.

Another important thing to note here is that Record() can be called at any time without context. In the case of Aegis, it’s called before (and outside of) whatever Capture() is wrapping. So there is no context. If we needed the context, we’d call Record() inside of Capture() which has context. Or, the specific context we care about.

By the time Tracer is given to your handler, you do have the relevant context. So you could leverage it to pull out whatever data you need.

The idea is that Record() shouldn’t require a context argument. While AWS X-Ray’s internal methods for recording annotations and metadata do take a context (for the segment), we don’t know if other trace services are going to use the context or not. So removing this requirement from the process keeps TraceStrategy a lot more flexible as an interface.

Tracing Functions

Capture() and CaptureAsync() (for goroutinues) are “wrappers.” They wrap the functions you wish to trace. In the case of AWS X-Ray, this captures things like execution time and more. Perhaps most important of all here is that they can capture errors.

What exactly will be traced is going to vary by the trace service (or your own code if you roll your own). In the case of AWS X-Ray, a lot is actually traced for you. How long HTTP connections took, how long it took to marshal things, and more.

One of the biggest things you’re going to see in X-Ray is how long your handlers took to execute and how long your Lambda took to execute. This really starts to give you a good idea about performance and where your bottlenecks are.

X-Ray trace example

An important thing to note about Lambda here is that X-Ray is also going to illustrate for you the difference between a “cold” and “warm” start in Lambda. You’ll notice in the screenshot above here that the Lambda took over 800ms to run. Not terrible for API Gateway and all the OAuth work that was going on, but also not stellar. Subsequent invocations were faster because the Lambda container was “warm” and some data retrieved from the IDP service (such as the DescribeUserPool and DescribeUserPoolClient calls) were already cached. Things like the well known ket set and so on.

As you learn to leverage caching methods and tune your service configurations, you’ll be able to visually see the performance change over time with X-Ray. It’s an incredibly useful service.

Custom

You can inject your own handler dependencies under the Custom field. This is great for 3rd party dependencies you wish to use as well as anything you want to make available to each handler.

These dependencies contain no “configuration” closure (unlike Services) and if your function is simple enough, you may not even need to pass them through Custom. Keep in mind the scoping in Go. If you’ve defined something in your package (“main” perhaps), it’s available to all of the functions within that package…This often will include your handlers.

So before you reach to shove everything through to your handlers, think about it a bit. Lambda functions should be easy to follow and your code should read clean. Remember that Custom here is a map[string]interface{} so you will likely encounter the need to check for nil and use type assertions.

Aegis isn’t trying to limit you and sometimes you may have no other option, but no one says you have to pass everything through to each handler in this way.

Services

From the Cognito example Aegis app

AegisApp.ConfigureService("cognito", func(ctx context.Context, evt map[string]interface{}) interface{} {
    // Automatically get host. You don't need to do this - you typically would have a known domain name to use.
    host := ""
    stage := "prod"
    if headers, ok := evt["headers"]; ok {
        headersMap := headers.(map[string]interface{})
        if hostValue, ok := headersMap["Host"]; ok {
            host = hostValue.(string)
        }
    }
    // Automatically get API stage
    if requestContext, ok := evt["requestContext"]; ok {
        requestContextMap := requestContext.(map[string]interface{})
        if stageValue, ok := requestContextMap["stage"]; ok {
            stage = stageValue.(string)
        }
    }

    return &aegis.CognitoAppClientConfig{
        // The following three you'll need to fill in or use secrets.
        // See README for more info, but set secrets using aegis CLI and update aegis.yaml as needed.
        Region:   "us-east-1",
        PoolID:   AegisApp.GetVariable("PoolID"),
        ClientID: AegisApp.GetVariable("ClientID"),
        // This is just automatic for the example, you would likely replace this too
        RedirectURI:       "https://" + host + "/" + stage + "/callback",
        LogoutRedirectURI: "https://" + host + "/" + stage + "/",
    }
})

Handler dependencies contain Services too. These are primarily AWS services like AWS Cognito for example. They are a bit different than a simple field on the Aegis interface because they are “services” that can carry configurations which often need to be dynamic.

However, services are really just dependencies as well. Your normal dependencies, set on the Aegis interface can not be configured dynamically. You set the dependency on the interface and that’s it.

Using ConfigureService() is the important function here. The first argument for this function is the name of the service you wish to configure. The second argument is a function. That function is given the context and the event from Lambda. However, this configuration is applied before your event handler is called.

Filters

There are hooks or “filters” available to use here for the handler as well. The Aegis interface has a field called Filters for various filters, the ones most helpful to services is likely the Filters.Handler.BeforeServices filter.

Custom Services

3rd party services can also be added and configured. They are available under the Custom field.

TBD - not fully implemented