Dev IRL: How to ingest Heroku Log Drains with Nodejs on AWS CloudWatch? Part 3: Handle the drains
Creating Helpers to build our architecture
Do you remember that we need a specific SQS and Log stream for each of our app? If not, got to the Part 1: Architecture đ
To have them, weâll need to create them before adding the new HTTPS drain Heroku-side so when the logs come, everything should be ready to ingest them. To do that we can use our heroku-drains Lambda function, but as you can imagine itâll begin to handle different tasks (at least two: creating and ingesting). And if you create something, you should also be able to delete it. And to list it. In other words Create, Read, Update and Delete items so yes, CRUD operations.
This story is the third of a 6 parts series. You can find all the other parts below:
- Part 1: Architecture
- Part 2: Get the logs
- Part 3: Handle the drains <<< đYou are here!
- Part 4: Sending events with SQS
- Part 5: Ingest the logs
- Part 6: The alert system
- Bonus Part: SpeedRun
Handle the HTTP methods
We can take advantage of the API Gatewayâs HTTP methods to implement a kind of RESTful API. I mean âkind ofâ because the POST
HTTP method will already be used by Heroku to send us the logs.
All in all, we could call our API with:
GET
: to list the drainsPOST
: to ingest the logsPUT
: to create a new drainDELETE
: to delete a drain
By default, the API can be accessed with any kind of method (remember the ANY keyword in the Routeâs details?) so the trick here will be to detect the method within our function. Letâs see how to do that:
As you can see, you can simply get the HTTP method in the event.requestContext.httpMethod
property, and execute different actions depending on that. In this example, we just return the method used along the appâs name as JSON response. You can test the various method with a tool like Postman.
We can now build our helpers to manage the SQS queues and Log streams! Just a quick note regarding the order of the methods, I test the POST
first as itâll be the most used by the Heroku drain.
List the drains
Letâs define that, in our context, a drain is composed by:
- a Log stream
- a SQS queue
- a Queue event source (the âlinkâ between a queue and our Lambda function)
But remember one thing: a Log stream resides in a Log group. So we must first create a Log group and then give our function the permissions to create, read and delete Log streams in that Log group.
Quick tip here: keep in mind that every action you make with an AWS API call must be granted through your Lambda function policy. So every time you use a new API endpoint or manipulate a new resource, you must update your policy. This is a best practice as you must give only the minimum permissions to an entity.
Go to your CloudWatch dashboard, create a new Log group and set its retention setting to 7 days (youâll be able to tune this setting later if you need to).
Now go to your heroku-drains Lambda functionâs Configuration > Permissions tab and click on its role name. Here, you can see the current policy applied to your function. Weâll add some others to manage our new Log group. Click on Edit and select the JSON editor (or use the visual editor if youâre more comfortable with).
Add the following statements (be aware that the names could change and I obfuscated the IDs):
{
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:DescribeLogStreams",
"logs:DeleteLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:eu-west-1:XXXXXXXX:log-group:/heroku/apps-test:*"
]
},
{
"Effect": "Allow",
"Action": [
"sqs:DeleteQueue",
"sqs:SendMessage",
"sqs:GetQueueAttributes",
"sqs:CreateQueue"
],
"Resource": [
"arn:aws:sqs:eu-west-1:XXXXXXXX:*"
]
},
{
"Effect": "Allow",
"Action": [
"sqs:ListQueues",
"lambda:ListEventSourceMappings",
"lambda:CreateEventSourceMapping"
],
"Resource": [
"*"
]
}
Okay, now we can use the AWS APIs to handle our Log streams, queues and queue event sources. One thing is missing though: we have created a Log group, but how could we access its name? It would be a bad idea to hardcode it in the Lambda function since it would turn the function specific to this very log group.
Instead, we could take advantage of the Lambda function environment variables! This will grant us the power of customization without updating the functionâs code. Go to the Configuration > Environment variables tab of the function and click on Edit. Add a variable (e.g. LOG_GROUP_NAME) and set the Log group name. The variable will be exposed in the process.env.LOG_GROUP_NAME
property within the function.
Getting a drainâs ressources
Weâll write a few helper functions to help us getting the resources. Weâll declare and define them outside the main even handler method though, to reuse them at each function invocation instead of recreating them. Typically, you must put this above the export async function handler(event){
statement:
Okay, thatâs a lot of helpers đ But believe me, its worth the effort! The various functions are pretty self-explanatory, so I wonât get into the details.
Just one thing though: keep in mind that the listQueues
, describeLogStreams
or listEventSourceMappings
are search functions. This is the reason why we must get the result that is an exact match of the search params.
We can now call getDrain
when our function is called with a GET
method, and return the result as a JSON object:
You should see an empty data
result as there is no drains created at the moment, but this is intended. Note that Iâve added prefixes to the Log stream and queue names, respectively [APP]
and APP_
, to tag them as âbelonging to an appâ and easily identify or search for them in the AWS console.
Creating a new drain
Okay letâs get serious now! We can write the helper function to create a new drain, again outside the event handler of the Lambda function:
Note that:
- we check the existence of each resource before creating them to ensure their unicity.
- this is during the creation phase that we set several parameters, like the
MaximumBatchingWindowInSecond
orBatchSize
ones we talked about in the Part 1: Architecture, that will take care of the invocation rate. - the queue event source is referencing the Lambda function dedicated to store the logs, whose name is stored in a new environment variable called
DRAIN_STORAGE_FUNCTION
. We need to create this function now to avoid anInvalidParameterValueException
.
From now on, weâll reference this function as the heroku-drains-storage Lambda function. Of course you can name it as you want, so go to your heroku-drains functionâs Configuration > Environment variables tab and add this name to a new DRAIN_STORAGE_FUNCTION
variable.
A basic Lambda setup for now
As we only need a valid reference to create the queue event source, we can just create a basic setup for our heroku-drains-storage Lambda function that will take care of the log storage itself. Go to your Lambda dashboard and:
- create a new function, with the same name as your
createEventSourceMapping
'sFunctionName
parameter. - choose Node.js 16.x as your runtime and arm64 as your architecture
- rename your
index.js
file asindex.mjs
to convert it to an ES module - update your code to log the incoming event and inform the SQS queue that everything went OK (i.e. no batch failures occurs) as below:
As we discussed earlier, creating a new resource means updating its permissions! Here, our function will need to receive messages from SQS and obviously put logs in the appâs dedicated Log group. Go to the Configuration > Permissions tab of your function and click on the roleâs name. Add the following statements:
{
"Effect": "Allow",
"Action": [
"sqs:DeleteMessage",
"sqs:ReceiveMessage",
"sqs:GetQueueAttributes"
],
"Resource": [
"arn:aws:sqs:eu-west-1:XXXXXXXX:*"
]
},
{
"Effect": "Allow",
"Action": [
"logs:DescribeLogStreams",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:eu-west-1:XXXXXXXXXX:log-group:/heroku/apps-test:log-stream:*"
]
}
Again, beware the names of your ressources and your IDs!
Test the endpoint
Now, letâs call createDrain
when our heroku-drains function is triggered with a PUT
method:
Note the prefixes I previously mentioned, this is where they are created đ So, after a PUT
call you should see the API responding with the newly created resources (at least the queue and the queue event source, as the createLogStream
method returns a empty body).
Now if you call the function with a GET
method, you should see the Log stream and queue infos of your app. You can also check your various resources via the AWS console:
- a new Log stream in the appâs Log group
- a new SQS queue
- a new SQS trigger on the heroku-drains-storage Lambda function that will store the logs
Delete a drain
The last step is to delete a drain. Letâs define our helper:
Again, this is pretty self-explanatory, we just ensure that the resource exists before deleting it. Now for the invocation during a DELETE
call:
Try it and you should be granted a success message đ
A CLI interface
Wouldnât it be nice if all of those CRUD operations could be executed by a single command line? Of course it would!
So the goal here is to write a small bash script with several options to help us list, create and delete log drains. Weâll assume that nodejs, NPM and the Heroku CLI are globally available in your system since, well, youâre likely to have those if you develop nodejs apps on Heroku đ
I wonât go into the implementation details of this script (and it could be optimized for sure), but here it is:
As a reminder, what we call a âdrainâ here is:
- a Heroku HTTP drain subscription
- an appâs specific SQS queue
- a Log stream dedicated to an app
So all you have to do is updating the BASE_URL
according to your own API Gateway endpoint, and execute:
sh heroku-drains-cli -a <myapp> --get
: to get infos about this drainsh heroku-drains-cli -a <myapp> --put
: to create a new drainsh heroku-drains-cli -a <myapp> --delete
: to delete a new drainsh heroku-drains-cli -a <myapp> --help
: to show the CLI usage
With these commands, both the Heroku and AWS sides are handled.
What have we learned?
- How to add environment variables to a Lambda function
- How to manage Lambda functions policies to give them proper permissions
- How to use different HTTP methods with the same Lambda function
- How to write a little bash script to handle the drains
Now, go to the next part: Sending events with SQS đ