Fastify Best Practices #6: Global Contextual Logging
In any server-side application, logging plays a crucial role. It’s not only a key clue for debugging bugs but also the cornerstone for monitoring system status and analyzing user behavior. An ideal logging system should be:
- Traceable: Every log entry should be linkable to its original request.
- Informative: Log content should include sufficient context, such as the API name, user information, etc.
- Easy to use: You should be able to record logs in a simple, unified way from anywhere in your project.
Fastify’s default logging capabilities are already powerful, but with a few best practices, we can build a truly enterprise-level logging system. This article will guide you step-by-step to achieve this goal.
Step 1: Customizing Unique Request Tracing IDs
To accurately pinpoint the entire process of a specific request from a vast amount of logs, we need a unique tracing ID (Trace ID or Request ID). Fastify allows us to customize the ID generation rule through the genReqId option.
The default IDs (e.g., req-1, req-2) are only unique within the current process’s lifecycle and will repeat when the service restarts or is deployed across multiple instances. It’s better to use a more robust unique string.
// main.ts
import fastify from 'fastify';
const server = fastify({
// This function is called when a request comes in to generate a request ID
genReqId(_req) {
// Use a random string to ensure high uniqueness
return Math.random().toString(36).substring(2, 12);
},
// ... other options
});
Now, every log entry output via request.log will automatically include a reqId field, like {"level":30,"time":167... ,"pid":...,"hostname":"...","reqId":"a1b2c3d4e5",...}.
Step 2: Injecting More Context (like API Names) into Logs
With reqId, we can trace a request. However, if we can directly see in the log “This log was generated by the ‘Get User Info’ API,” troubleshooting becomes more efficient.
We can achieve this using Fastify’s Hooks and Route Schema. In Fastify Best Practices #5: Generating Swagger Docs, we set a summary field for each API to describe its purpose, which is perfect for use as log context.
We’ll create an onRequest hook, which executes before each request enters its route handler.
// hooks/request.hook.ts
import { FastifyPluginAsync } from 'fastify';
import fp from 'fastify-plugin';
const requestHook: FastifyPluginAsync = fp(async (fastify) => {
fastify.addHook('onRequest', (request, _reply, done) => {
// 1. Get the API summary from the route definition
const summary = request.routeOptions.schema?.summary || '';
// 2. Create a child logger based on the current request's logger and add extra info
const childLogger = request.log.child({ api: summary });
// 3. Overwrite the current request's logger with the new child logger
request.log = childLogger;
done();
});
});
export default requestHook;
After this step, logs output using request.log.info('...') will automatically include both the reqId and api fields.
Step 3: The Core Challenge and the Ultimate Solution: AsyncLocalStorage
Everything looks good so far, but there’s one remaining pain point: we must use the request object to call logging methods (request.log.info(...)).
This means if you want to log something in a deeply nested service function or a standalone utility function, you have to pass the request object or request.log all the way down. This is not only cumbersome but also severely damages code cleanliness and encapsulation.
The ideal state we want to achieve is: to import a global logger anywhere, call logger.info() directly, and have it automatically associate with the context of the currently processing request.
It sounds like magic, and the tool to perform this magic is AsyncLocalStorage, part of the async_hooks module built into Node.js v13.10+.
Demystifying: What is AsyncLocalStorage?
You can think of AsyncLocalStorage as a ”request-specific invisible backpack.”
- Creating the backpack: First, you create a global instance of
AsyncLocalStoragein your application. This is like designing the backpack. - Distributing the backpack: When an HTTP request comes in, you call
als.run(store, callback). This action effectively gives a new backpack to the entire subsequent chain of operations for this request (including allawait,.then(), callbacks, etc.). Thestoreis what you want to put in the backpack—in our case, theloggerinstance that contains thereqIdandapi. - Accessing it anytime: During the processing of this request, no matter how deep the code execution goes, as long as it’s part of the asynchronous chain triggered by the initial request, you can use
als.getStore()to grab this request’s specific backpack and take things out. - Request isolation: Different requests get different backpacks, and their data is completely isolated. Request A can never access the contents of Request B’s backpack. This is crucial for handling concurrent requests.
In short, AsyncLocalStorage provides a mechanism similar to “thread-local storage” for Node.js asynchronous operations, perfectly solving the long-standing problem of safely passing context through an async flow.
Step 4: Integrating AsyncLocalStorage to Build a Global Logger
Now, let’s refactor our logging system using AsyncLocalStorage.
1. Create the AsyncLocalStorage Utility
// utils/als.util.ts
import { AsyncLocalStorage } from 'async_hooks';
import { FastifyBaseLogger } from 'fastify';
// Define the data structure we want to store in the "backpack"
export type IAsyncStore = {
logger: FastifyBaseLogger;
};
// Create a single, global instance of AsyncLocalStorage
const asyncStore = new AsyncLocalStorage<IAsyncStore>();
// Provide a safe method to get the logger
const getLogger = (): FastifyBaseLogger => {
const store = asyncStore.getStore();
// If getStore() returns undefined, it means the current execution context is not within an als.run() call.
// This usually indicates a logical error, like calling a business logger during service startup.
if (!store) {
throw new Error('Logger could not be found in the current async context.');
}
return store.logger;
};
export const ALS = {
asyncStore,
getLogger,
};
2. Refactor the onRequest Hook
We’ll modify the previous hook to wrap the entire request lifecycle with als.run.
// hooks/request.hook.ts
import { ALS } from '@/utils/als.util';
import { FastifyPluginAsync } from 'fastify';
import fp from 'fastify-plugin';
const requestHook: FastifyPluginAsync = fp(async (fastify) => {
fastify.addHook('onRequest', (request, _reply, done) => {
const summary = request.routeOptions.schema?.summary || '';
const childLogger = request.log.child({ api: summary });
request.log = childLogger;
// Key: Wrap all subsequent operations within the run method.
// We store the context-aware logger instance in the store.
ALS.asyncStore.run({ logger: childLogger }, () => {
// The context is automatically destroyed after the run callback finishes.
done();
});
});
});
export default requestHook;
3. Create the Global logger Utility
This is the final and most elegant step. We create a logger object that can be imported by any file in the project.
// utils/logger.util.ts
import { ALS } from './als.util';
import { FastifyBaseLogger } from 'fastify';
// We only expose the necessary log level methods
export const logger: Pick<FastifyBaseLogger, 'info' | 'error' | 'warn' | 'debug'> = {
// Using a getter here is the key!
// It ensures that ALS.getLogger() is executed at the moment logger.info() is actually called,
// not when the module is first loaded.
get info() {
const log = ALS.getLogger();
return log.info.bind(log);
},
get error() {
const log = ALS.getLogger();
return log.error.bind(log);
},
get warn() {
const log = ALS.getLogger();
return log.warn.bind(log);
},
get debug() {
const log = ALS.getLogger();
return log.debug.bind(log);
},
};
The Final Result: Effortless Logging, Anywhere
Now, whether you’re in a Controller, a Service, or any other module, logging is extremely simple:
// services/user.service.ts
import { logger } from '@/utils/logger.util';
export class UserService {
async findUser(userId: string) {
logger.info({ userId }, 'Starting to find user...'); // This log will automatically include the reqId and api
// ... business logic ...
if (!user) {
logger.warn({ userId }, 'User not found.'); // This log will too
return null;
}
logger.info({ userId, username: user.name }, 'User found successfully.');
return user;
}
}
You no longer need to worry about where the request object is. Just import the global logger, and it will automatically find the correct logger instance for the current request from the “invisible backpack” (AsyncLocalStorage) and output your log message along with all its context.
Conclusion
Through these four steps, we have built a powerful, elegant, and maintainable logging system in Fastify:
genReqId: Ensures log traceability.onRequestHook: Dynamically enriches log context.AsyncLocalStorage: Solves the age-old problem of passing context through asynchronous flows.- Global Logger Utility: Provides an extremely simple developer experience.
This approach is not just for logging; any data that needs to be tied to the request lifecycle (like user info, tenant ID, etc.) can be managed in a similar way, making it an essential tool in modern Node.js backend architecture.