DEV Community

Cover image for First Look at Lambda Powertools TypeScript
Matt Morgan for AWS Community Builders

Posted on • Updated on

First Look at Lambda Powertools TypeScript

AWS Senior Solutions Architect Sara Gerion announced on January 5, 2022 that Lambda Powertools TypeScript had entered public beta. Lambda Powertools is an AWS-sponsored open source project with the goal of improving developer experience and adoption of best practices when using AWS Lambda. Lambda Powertools TypeScript joins Java and Python Lambda Powertools libraries.

Table of Contents

Node.js Tooling for Lambda

I'm a big fan of TypeScript and in fact co-authored a book about it. I don't find myself using Java or Python much, so while I've been interested in Lambda Powertools, I never tried it out until now. Lambda Powertools TypeScript joins middy and DAZN Lambda Powertools in the Lambda tooling space for the Node.js runtime. Two things that differentiate Lambda Powertools TypeScript from comparable libraries are it is sponsored by AWS and it supports decorators.

Lambda Powertools TypeScript supports both v2 and v3 of the AWS SDK for JavaScript and examples are given for both versions.

Decorators

Opinions vary on decorators, but I find them to be useful abstractions and have used them heavily in object-oriented TypeScript. However, there's a fairly major limitation in TypeScript and that is that we can put decorators on class methods, but not functions. This means, unfortunately, code that looks like this is out of reach for now.

// This doesn't work :(
@doSomethingGreat()
export const handler = async () => {
  ...
};
Enter fullscreen mode Exit fullscreen mode

It's a pity because that would be an excellent developer experience. To attach the doSomethingGreat decorator to our handler, we need to write something like this instead.

class Lambda {
  @doSomethingGreat()
  public async handler() {
    ...
  }
}

const handlerClass = new Lambda();
export const handler = handlerClass.handler;
Enter fullscreen mode Exit fullscreen mode

It's five extra lines - perhaps not a big deal. In any case, there's not a lot the Powertools team can do as it may well be years before decorators are supported for functions. Keep in mind if we do choose to use decorators and classes in Lambda, we need to be careful around the this reference.

Decorators and TypeScript aren't supported out of the box in Lambda (without using deno) so we'll also need a transpilation step if we go this route. Fortunately this is a mostly solved problem for AWS CDK, AWS SAM and Serverless Framework users. If you want or need to roll your own, esbuild is a great place to start and seems to be the bundler of choice for this purpose.

No Decorators

We may wish to opt out of using classes or transpilers. Lambda Powertools TypeScript can be used without decorators and in fact without TypeScript. We could use the library with vanilla Node.js.

The documentation for Lambda Powertools TypeScript is pretty good and provides several examples with even more on GitHub.

Capabilities

All three versions of Lambda Powertools include Metrics, Logger and Tracer as core utilities. Lambda Powertools Python includes an Event Handler and several other useful utilities supporting Batch Processing, Idempotency, Validation and more.

Each version of Lambda Powertools is developed independently and the capabilities are custom-tailored to the distinct needs of the different runtimes. This is in contrast to AWS CDK using jsii to publish the same constructs to multiple runtimes. Although it may be frustrating to wait for some of these capabilities, this is likely the right approach as it would just add to the complexity to think about generic code that compiles to something that can decorate custom code supported in multiple runtimes.

To test this out, I've implemented all three of the Lambda Powertools TypeScript utilities in one of my sample projects. I chose my CDK Async Testing project because it contains several Lambda functions and includes asynchronous workflows via EventBridge and Step Functions.

If you want to see my instrumented code, it is available in this branch.

Tracer

In order to instrument my functions with the Tracer, I needed to rewrite them as classes. I chose to use decorators because I'm really undecided about whether to start using classes everywhere, so I need to get a feel for it. Starting off with collect.ts, here's the function as it was originally at eleven lines of code.

import { Payment } from '../models/payment';

export const handler = async (input: {
  Payload: { Payment: Payment };
}): Promise<{ Status: number; Payment: Payment }> => {
  const min = 0;
  const max = 1;
  const Status = Math.floor(Math.random() * (max - min + 1)) + min;

  return { Status, Payment: input.Payload.Payment };
};
Enter fullscreen mode Exit fullscreen mode

And here's the refactor to include the Tracer.

import { Tracer } from '@aws-lambda-powertools/tracer';

import type { LambdaInterface } from '@aws-lambda-powertools/commons';
import type { Context } from 'aws-lambda';

import type { Payment } from '../models/payment';

const tracer = new Tracer({ serviceName: 'paymentCollections' });

class Lambda implements LambdaInterface {
  @tracer.captureLambdaHandler()
  public async handler(
    input: { Payload: { Payment: Payment } },
    _context: Context,
  ): Promise<{ Status: number; Payment: Payment }> {
    const min = 0;
    const max = 1;
    const Status = Math.floor(Math.random() * (max - min + 1)) + min;

    return { Status, Payment: input.Payload.Payment };
  }
}

const handlerClass = new Lambda();
export const handler = handlerClass.handler;
Enter fullscreen mode Exit fullscreen mode

The function is now 25 lines of code, which isn't horrible. It ends up at 33 lines after adding in the Metrics and Logger utilities. Of course it's expected that my function will be larger as it has more capabilities now. We should see the increase as additive, not multiplicative. My 11 line function became 25 lines. Had it originally been 111 lines, it would've grown to 125, not doubled.

So what do we get for that? The Tracer module wraps the AWS X-Ray SDK (as a transitive dependency). It doesn't really add any net new capabilities, but makes the SDK easier to work with. In my experience, that SDK is a bit of a bear so this may be well worth it. We can decorate class methods to introduce new trace segments in a single line of code. We can also use the imperative form to add new traces where we see fit. We can capture AWS clients, but that simply exposes the X-Ray SDK.

One thing that isn't solved here (yet?) is the oddness of working with the X-Ray SDK and DynamoDB DocumentClient. When using DocumentClient with X-Ray, we need to do a workaround as the SDK needs access to the DocumentClient service property, but that's not exposed on the type. Here's how I've solved that problem in the past.

import DynamoDB, { DocumentClient } from 'aws-sdk/clients/dynamodb';
import { captureAWSClient } from 'aws-xray-sdk-core';

const client = new DocumentClient();
captureAWSClient((client as DocumentClient & { service: DynamoDB }).service);
Enter fullscreen mode Exit fullscreen mode

UPDATE! This was solved in release 0.5.0! The above code can now be written as:

import { DocumentClient } from 'aws-sdk/clients/dynamodb';
import { captureAWSClient } from 'aws-xray-sdk-core';

const client = new DocumentClient();
captureAWSClient(client);
Enter fullscreen mode Exit fullscreen mode

Very much appreciate the quick turnaround on this improvement! Now that I have better DX, here's what my instrumented app looks like.

original text
And now with Lambda Powertools TypeScript.
import DynamoDB, { DocumentClient } from 'aws-sdk/clients/dynamodb';
import { Tracer } from '@aws-lambda-powertools/tracer';

const tracer = new Tracer({ serviceName: 'paymentCollections' });
const client = new DocumentClient();
tracer.captureAWSClient((client as DocumentClient & { service: DynamoDB }).service);
Enter fullscreen mode Exit fullscreen mode

So basically the same thing. The advantage of this utility will really only come into play vs just using the X-Ray SDK if we're adding several segments to our code. I don't think I'm unlocking that in my sample application, but I did enable tracing in all of my functions and my state machine and the result is pretty good.


ServiceLens Map

Not only do I get this great service map, I also get detailed traces.

Trace #1

Trace #2

The Tracer module is adding the ## index.handler part seen on these screenshots. I'll want to add more traces to really take advantage of this tool. Overall getting these detailed traces of my entire application through all the functions and services is quite impressive and useful. X-Ray is doing most of the work, but a better developer experience means we'll wind up with more applications instrumented properly and that's certainly a good thing.

Getting ahead of myself slightly here, but traces also include logs and logs for the instrumented segments will be found alongside the traces in CloudWatch.

Trace logs

Again, this is great. I'm able to write my application in a distributed way that uses single-purpose functions, but see the whole picture when it executes.

X-Ray can be a fairly cheap service to use provided we remember to set a sampling rate on high-throughput applications. Pricing seems to be based on trace so adding additional segments to a trace doesn't add to the cost.

Logger

The Logger utility is a drop-in replacement for any logger, including console. The value add Logger brings is the ability to inject the Lambda context into each logging message. When we annotate our handler method with @logger.injectLambdaContext(), then use logger.info, we'll see log messages that look like this.

{
  "cold_start": true,
  "function_arn": "arn:aws:lambda:us-east-1:1234567890:function:openCollection",
  "function_memory_size": 128,
  "function_name": "openCollection",
  "function_request_id": "df21844d-f4b6-49b4-b246-da54183ce5cf",
  "level": "INFO",
  "message": "Payment set to collection",
  "service": "paymentCollections",
  "timestamp": "2022-01-09T18:46:20.622Z"
}
Enter fullscreen mode Exit fullscreen mode

If we plan to ingest logs into a search index or even if we just want to use CloudWatch Logs Insights, this can be really handy as the structure will help us to search and filter log messages. On the other hand, if we're just going through a few log messages, this can be a bit noisy. We should keep in mind that any log service (including CloudWatch) is going to bill on volume and extremely verbose logs can be expensive.

With that in mind, there are a lot of good features for the Logger utility and we can structure our logs however we like. Additionally Logger includes a sampling rate feature which can be used to keep costs down.

By default logger methods take one or more arguments. The first argument needs to be a string or object with a message key. I noticed that if I gave a string as a subsequent argument, the string would be converted into a character array and printed like that, so that's something to watch out for.

Metrics

The Metrics utility is used to publish custom CloudWatch metrics. Although Lambda automatically publishes a number of useful metrics like latency, concurrent executions, throttling, etc., custom metrics give us the opportunity to complete the observability story by adding relevant business events to our metrics.

Tracking reliability is important, but it does not tell the whole story! If anything, custom metrics should be the most important ones. How many customers signed up this week? How many of them were able to complete valuable workflows? The answers to these questions lie in our code and if we emit custom metrics, they can also be in our dashboards.

Custom metrics have a pricing structure which can be expensive. Embedded Metrics Format can help manage the cost and is supported by Lambda Powertools TypeScript. Again, the docs here are pretty good, so no need for me to break it down. Instead let's look at the experience. I've added a custom metric of "collectionSuccess" to my collectionSuccess function. In my hypothetical app, some payments wind up in collections and here I'm marking whether or not the collection resulted in a payment.

import { Logger } from '@aws-lambda-powertools/logger';
import { Metrics, MetricUnits } from '@aws-lambda-powertools/metrics';
import { Tracer } from '@aws-lambda-powertools/tracer';

import { PaymentEntity, PaymentStatus } from '../models/payment';

import type { LambdaInterface } from '@aws-lambda-powertools/commons';
import type { Context } from 'aws-lambda';
import type { Payment } from '../models/payment';

const powerToolsConfig = { namespace: 'payments', serviceName: 'paymentCollections' };
const logger = new Logger(powerToolsConfig);
const metrics = new Metrics(powerToolsConfig);
const tracer = new Tracer(powerToolsConfig);

class Lambda implements LambdaInterface {
  @logger.injectLambdaContext()
  @metrics.logMetrics({ captureColdStartMetric: true })
  @tracer.captureLambdaHandler()
  public async handler(input: { Payload: { Payment: Payment } }, _context: Context): Promise<void> {
    try {
      await PaymentEntity.update({ id: input.Payload.Payment.id, status: PaymentStatus.COLLECTION_SUCCESS });
      metrics.addMetric('collectionSuccess', MetricUnits.Count, 1);
    } catch (e) {
      logger.error('Failed to record collection success', e);
      throw e;
    }
  }
}

const handlerClass = new Lambda();
export const handler = handlerClass.handler;
Enter fullscreen mode Exit fullscreen mode

Adding @metrics.logMetrics will cause any metrics we emit to also be logged to CloudWatch. We may or may not want that (keeping costs in mind again). To add a custom metric, we simply use metrics.addMetric.

I instrumented my app to emit metrics for cold starts as well as some custom metrics that describe important events in my application, such as successful and failed payments and collections. Because the point of my application is to demonstrate an integration test, I also instrumented custom metrics that indicate when the test is run.

CloudWatch Metrics

These metrics can be found in CloudWatch Metrics, placed in a dashboard or exported via API to a 3rd party tool.

Package Size

Adding all of the utilities to my project seemed to add about 600kb unminified or 200kb to minified bundles. Given the value and the need to chain some dependencies into the AWS SDK or X-Ray SDK, this seems quite reasonable and the team has done a good job of staying with their Keep it lean tenent.

Conclusion

Lambda Powertools does an excellent job putting focus on the kinds of utilities developers really need to improve their applications and follow best practices. The core modules here all focus on observability and that emphasis is needed and appreciated. The team has done a good job developing an API that will be attractive to developers who want to use decorators as well as those who don't.

I eagerly await the general availability of this library and will follow the roadmap to see what's coming up and how I can get involved.

COVER

Discussion (13)

Collapse
loujaybee profile image
Lou (πŸš€ Open Up The Cloud ☁️)

Really nice write-up, Matt!

Collapse
aditmodi profile image
Adit Modi

Hi @elthrasher,
Nice article, I was looking forward to exploring the lambda powertools and This article is a good starting point.

lambda powertools is great at providing best practices around observability and will be helpful for developers. Really helpful stuff.

Collapse
ijemmy profile image
ijemmy

Hi Matt,
I work on this project. Thanks a lot for reviewing the tool :) This is very valuable for us. We have been pushing ourselves to publish beta version to get a feedback like this so we could reiterate on the right path.

Totally agree on the point that decorator doesn't work on functions. Making functions into a class just to use the decorator feature really bugs me. This led to Middy support as an alternative. We (maintainers) are also concerned if it's commonly used enough. So we ended up supporting all 3 options.

Collapse
elthrasher profile image
Matt Morgan Author

Hey ijemmy, thanks for reading! I think you all made the right choice in supporting all three methods. I've been a middy user for years and I like what it provides, but have always disliked the API (while understanding why they made it that way). I'll probably stick with decorators and see how that goes, but you'll need the other methods anyway to support users who don't want to use transpilers.

Anyway, enjoy the positive feedback! Your team did a great job on this in spite of limitations in the language!

Collapse
gerardolima profile image
Gerardo Lima

Classes without state, decorators, ... it's feels like a great option for folks who miss java in early 2000s

Collapse
elthrasher profile image
Matt Morgan Author • Edited on

I don't miss the build times.

build times

Collapse
gerardolima profile image
Gerardo Lima

hey, @matt, maybe my point wasn't clear; what I meant is that I don't miss using stateless classes as namespaces for static methods and neither do I miss introducing magic on the codebase with anotations; I see these as bad practices on TypeScript codebases and I prefer to avoid them as much as I can and keeping the codebase clean and readable.

Thread Thread
elthrasher profile image
Matt Morgan Author

I can appreciate that perspective. I like using decorators (and I liked annotations in Java) because they reduce boilerplate code. There are definitely pros and cons and one thing to consider is the developers and teams that will need to interact with the code.

I've used decorators in express apps for years and I'm a big fan of putting something like @Authorized on a route instead of if(!sessionNotValid(req)) throw Error(401) or something like that. Remains to be seen whether I'll like using decorators in Lambda as much, but I can think of a lot more boilerplate that could be rolled up....

Thread Thread
gerardolima profile image
Gerardo Lima

In Node applications, I don't use decorators and neither do I use if ... statements to check for authorization inside handlers -- that's what's the middlewares are for...

Nevertheless, at the end of the day, it's a matter of preference. Just keep in mind that support for decorators in typescript is not stable and you might have your build pipeline constrained to the tools that support it and you may encounter some issues when upgrading tsc compiler, for example.

Collapse
drupsys profile image
drupsys • Edited on

Well... this solution was designed by someone who has never heard a word "Patterns", just spray syntactic sugar everywhere indiscriminately to solve all your problem.

Could have just used a decorator pattern, would have been backwards compatible with JavaScript, work with any kind of function, need no classes and actually be more compact than the code above...

Collapse
drupsys profile image
drupsys • Edited on

sorry, I'm a bit annoyed in case you can't tell, but seriously we couldn't just do something like this?

import { decorate, Tracer } from '@aws-lambda-powertools/tracer';
import type { Context } from 'aws-lambda';
import type { Payment } from '../models/payment';

const {
    captureLambdaHandler,
    superDuperSpecialStuff,
} = Tracer({ serviceName: 'paymentCollections' }); // This can't be a class anymore, because javascript...

const handler = async (
    input: { Payload: { Payment: Payment } },
    _context: Context,
): Promise<{ Status: number; Payment: Payment }> => {
    const min = 0;
    const max = 1;
    const Status = Math.floor(Math.random() * (max - min + 1)) + min;

    return { Status, Payment: input.Payload.Payment };
}

export default decorate(
    handler,
    captureLambdaHandler,
    superDuperSpecialStuff,
    // ...
);
Enter fullscreen mode Exit fullscreen mode
Collapse
elthrasher profile image
Matt Morgan Author

What you want is supported using middy. You can use class decorators, middy-style function "decorators", or inline imperative statements. Check the docs and you'll see each example has a "Middy Middleware" example.

Collapse
michaelbrewer profile image
Michael Brewer

At least one of the DX improvements have been made :)

twitter.com/dreamorosi/status/1486...