Using Async Hooks for Request Context Handling in Node.js

Introduction

Async Hooks are a core module in Node.js that provides an API to track the lifetime of asynchronous resources in a Node application. An asynchronous resource can be thought of as an object that has an associated callback.

Examples include, but are not limited to: Promises, Timeouts, TCPWrap, UDP etc. The whole list of asynchronous resources that we can track using this API can be found here.

The Async Hooks feature was introduced in 2017, in Node.js version 8 and is still experimental. This means that backwards-incompatible changes may still be made to future releases of the API. That being said, it's currently not deemed fit for production.

In this article, we will take a deeper look at Async Hooks - what they are, why they are important, where we can use them and how we can leverage them for a particular use case, that is, request context handling in a Node.js and Express application.

What are Async Hooks?

As stated earlier, the Async Hooks class is a core Node.js module that provides an API for tracking asynchronous resources in your Node.js application. This also includes tracking of resources created by native Node modules such as fs and net.

During the lifetime of an async resource, there are 4 events which fire and we can track, with Async Hooks. These include:

  1. init - Called during the construction of the async resource
  2. before - Called before the callback of the resource is called
  3. after - Called after the callback of the resource has been invoked
  4. destroy - Called after the async resource is destroyed
  5. promiseResolve - Called when the resolve() function of a Promise is invoked.

Below is a summarized snippet of the Async Hooks API from the overview in the Node.js documentation:

const async_hooks = require('async_hooks');

const exec_id = async_hooks.executionAsyncId();
const trigger_id = async_hooks.triggerAsyncId();
const asyncHook = async_hooks.createHook({
  init: function (asyncId, type, triggerAsyncId, resource) { },
  before: function (asyncId) { },
  after: function (asyncId) { },
  destroy: function (asyncId) { },
  promiseResolve: function (asyncId) { }
});
asyncHook.enable();
asyncHook.disable();

The executionAsyncId() method returns an identifier of the current execution context.

The triggerAsyncId() method returns the identifier of the parent resource that triggered the execution of the async resource.

The createHook() method creates an async hook instance, taking the aforementioned events as optional callbacks.

To enable tracking of our resources, we call the enable() method of our async hook instance which we create with the createHook() method.

We can also disable tracking by calling the disable() function.

Having seen what the Async Hooks API entails, let's look into why we should use it.

When to use Async Hooks

The addition of Async Hooks to the core API has availed many advantages and use cases. Some of them include:

  1. Better debugging - By using Async Hooks, we can improve and enrich stack traces of async functions.
  2. Powerful tracing capabilities, especially when combined with Node's Performance API. Also, since the Async Hooks API is native, there is minimal performance overhead.
  3. Web request context handling - to capture a request's information during the lifetime of that request, without passing the request object everywhere. Using Async Hooks this can be done anywhere in code and could be especially useful when tracking the behavior of users in a server.

In this article, we will be looking at how to handle request ID tracing using Async Hooks in an Express application.

Using Async Hooks for Request Context Handling

In this section, we will be illustrating how we can leverage Async Hooks to perform simple request ID tracing in a Node.js application.

Setting up Request Context Handlers

We'll begin by creating a directory where our application files will reside, then move into it:

mkdir async_hooks && cd async_hooks 

Next, we'll need to initialize our Node.js application in this directory with npm and default settings:

npm init -y

This creates a package.json file at the root of the directory.

Next, we'll need to install Express and uuid packages as dependencies. We will use the uuid package to generate a unique ID for each incoming request.

Finally, we install the esm module so Node.js versions lower than v14 can run this example:

npm install express uuid esm --save

Next, create a hooks.js file at the root of the directory:

touch hooks.js

This file will contain the code that interacts with the async_hooks module. It exports two functions:

  • One that enables an Async Hook for an HTTP request, keeping track of its given request ID and any request data we'd like to keep.
  • The other returns the request data managed by the hook given its Async Hook ID.

Let's put that into code:

require = require('esm')(module);
const asyncHooks = require('async_hooks');
const { v4 } = require('uuid');
const store = new Map();

const asyncHook = asyncHooks.createHook({
    init: (asyncId, _, triggerAsyncId) => {
        if (store.has(triggerAsyncId)) {
            store.set(asyncId, store.get(triggerAsyncId))
        }
    },
    destroy: (asyncId) => {
        if (store.has(asyncId)) {
            store.delete(asyncId);
        }
    }
});

asyncHook.enable();

const createRequestContext = (data, requestId = v4()) => {
    const requestInfo = { requestId, data };
    store.set(asyncHooks.executionAsyncId(), requestInfo);
    return requestInfo;
};

const getRequestContext = () => {
    return store.get(asyncHooks.executionAsyncId());
};

module.exports = { createRequestContext, getRequestContext };

In this piece of code, we first require the esm module to provide backwards compatibility for Node versions that do not have native support for experimental module exports. This feature is used internally by the uuid module.

Next, we also require both the async_hooks and uuid modules. From the uuid module, we destructure the v4 function, which we shall use later to generate version 4 UUIDs.

Next, we create a store that will map each async resource to its request context. For this, we utilize a simple JavaScript map.

Next, we call the createHook() method of the async_hooks module and implement the init() and destroy() callbacks. In the implementation of our init() callback, we check if the triggerAsyncId is present in the store.

If it exists, we create a mapping of the asyncId to the request data stored under the triggerAsyncId. This in effect ensures that we store the same request object for child asynchronous resources.

The destroy() callback checks if the store has the asyncId of the resource, and deletes it if true.

To use our hook, we enable it by calling the enable() method of the asyncHook instance we've created.

Next, we create 2 functions - createRequestContext() and getRequestContext that we use to create and get our request context respectively.

The createRequestContext() function receives the request data and a unique ID as arguments. It then creates a requestInfo object from both arguments and attempts to update the store with the async ID of the current execution context as key, and the requestInfo as the value.

The getRequestContext() function, on the other hand, checks if the store contains an ID corresponding to the ID of the current execution context.

We finally export both functions using the module.exports() syntax.

We've successfully set up our request context handling functionality. Let's proceed to set up our Express server that will receive the requests.

Setting up the Express Server

Having set up our context, we'll now proceed to create our Express server so we can capture HTTP requests. To do so, create a server.js file at the root of the directory as follows:

touch server.js

Our server will accept an HTTP request on port 3000. It creates an Async Hook to track every request by calling the createRequestContext() in a middleware function - a function that has access to an HTTP's request and response objects. The server then sends a JSON response with the data captured by the Async Hook.

Inside the server.js file, enter the following code:

const express = require('express');
const ah = require('./hooks');
const app = express();
const port = 3000;

app.use((request, response, next) => {
    const data = { headers: request.headers };
    ah.createRequestContext(data);
    next();
});

const requestHandler = (request, response, next) => {
    const reqContext = ah.getRequestContext();
    response.json(reqContext);
    next()
};

app.get('/', requestHandler)

app.listen(port, (err) => {
    if (err) {
        return console.error(err);
    }
    console.log(`server is listening on ${port}`);
});

In this piece of code, we require express and our hooks modules as dependencies. We then create an Express app by calling the express() function.

Next, we set up a middleware that destructures the request headers saving them to a variable called data. It then calls the createRequestContext() function passing data as an argument. This ensures that the headers of the request will be preserved throughout the request's lifecycle with the Async Hook.

Finally, we call the next() function to go to the next middleware in our middleware pipeline or invoke the next route handler.

After our middleware, we write the requestHandler() function that handles a GET request on the server's root domain. You'll notice that in this function, we can have access to our request context through the getRequestContext() function. This function returns an object representing the request headers and request ID generated and stored in the request context.

We then create a simple endpoint and attach our request handler as a callback.

Finally, we make our server listen to connections on port 3000 by calling the listen() method of our app instance.

Before running the code, open the package.json file at the root of the directory and replace the test section of the script with this:

"start": "node server.js"

This done, we can run our app with the following command:

npm start

You should receive a response on your terminal indicating that the app is running on port 3000, as shown:

> [email protected] start /Users/allanmogusu/StackAbuse/async-hooks-demo
> node server.js

(node:88410) ExperimentalWarning: Conditional exports is an experimental feature. This feature could change at any time
server is listening on 3000

With our app running, open a separate terminal instance and run the following curl command to test out our default route:

curl http://localhost:3000

This curl command makes a GET request to our default route. You should get a response similar to this:

$ curl http://localhost:3000
{"requestId":"3aad88a6-07bb-41e0-ab5a-fa9d5c0269a7","data":{"headers":{"host":"localhost:3000","user-agent":"curl/7.64.1","accept":"*/*"}}}%

Notice that the generated requestId and our request headers are returned. Repeating the command should generate a new request ID since we shall be making a new request:

$ curl http://localhost:3000
{"requestId":"38da84792-e782-47dc-92b4-691f4285b172","data":{"headers":{"host":"localhost:3000","user-agent":"curl/7.64.1","accept":"*/*"}}}%

The response contains the ID we generated for the request and the headers we captured in the middleware function. With Async Hooks, we could easily pass data from one middleware to another for the same request.

Conclusion

Async Hooks provides an API for tracking the lifetime of asynchronous resources in a Node.js application.

In this article, we have looked briefly at the Async Hooks API, the functionality it provides, and how we can leverage it. We have specifically covered a basic example of how we can use Async Hooks to do web request context handling and tracing efficiently and cleanly.

Since Node.js version 14 however, the Async Hooks API ships with async local storage, an API that makes request context handling easier in Node.js. You can read more about it here. Also, the code for this tutorial can be accessed here.

Author image
About Allan Mogusu
Nairobi, Kenya Twitter