Centralized Logging and Error Handling in NodeJS
A typical NodeJS API repository often includes multiple components such as controllers, services, and repositories. In a standard setup, you might find files like someController.js
, someService.js
, and someRepository.js
.
+-------------+ +-----------+ +-------------+
| Controller | ---> | Service | ---> | Repository |
+-------------+ +-----------+ +-------------+
^ ^ ^
| | |
API Business Logic Database
Request Processing Access
For example:
someController.js
import db from './db'; // assuming you have a db module for transactions
import someService from './someService'; // assuming you have some service
const someController = {
controllerFunction: async (req, res) => {
const transaction = await db.startTransaction();
try {
await someService.performAction(req.body, transaction);
await transaction.commit();
res.status(200).send({ message: 'Success' });
} catch (error) {
console.error(`Error in [someController]:[controllerFunction] ${error}`);
await transaction.rollback();
res.status(500).send({ error: 'An error occurred' });
}
}
}
export default someController;
someService.js
import db from './db'; // assuming you have a db module for transactions
import someRepository from './someRepository'; // assuming you have some repository
const someService = {
performAction: async (data, transaction) => {
try {
await someRepository.saveData(data, transaction);
} catch (error) {
console.error(`Error in [someService]:[performAction] ${error}`);
throw error;
}
}
};
export default someService;
someRepository.js
import db from './db'; // assuming you have a db module for transactions
const someRepository = {
saveData: async (data, transaction) => {
try {
await db.insert(data, {
transaction: transaction
});
} catch (error) {
console.error(`Error in [someRepository]:[saveData] ${error}`);
throw error;
}
}
};
export default someRepository;
Each time you need to handle errors gracefully, you end up writing try-catch blocks. If you want to trace the function call sequence throughout a feature's journey then you either launch a debugger like a weird developer or if you’re sane you’ll write something like:
console.log('Calling function abc(why god why:!!)');
const res = await abc();
console.log('res of abc is', res);
return res;
What if you never had to do this and everything just works out on its own?
Pretty neat thought, right?
Well there’s a way.
Providing you some sample snippets which are self explanatory:
utils.js
export const wrapFunctionWithLogging = (fn, fileName) => {
return async function (...args) {
try {
console.log(`Calling function [${fileName}]->${fn.name} with arguments:`, args);
const result = await fn(...args);
console.log(`Result from function [${fileName}]->${fn.name}:`, result);
return result;
} catch (error) {
console.error(`Error in function [${fileName}]->${fn.name}:`, error.message);
throw error;
}
};
}
export const wrapFunctions = (obj, fileName)=>{
const wrapped = {};
for (const [key, fn] of Object.entries(obj)) {
if (typeof fn === "function") {
wrapped[key] = wrapFunctionWithLogging(fn, fileName);
} else {
wrapped[key] = fn;
}
}
return wrapped;
}
export const sleep = (timeInSec) => {
return new Promise((resolve)=> {
resolve()
}, timeInSec * 1000)
}
userService.js
import { sleep, wrapFunctions } from './utils.js'
const userService = {
getAllUsers: async () => {
await sleep(3);
return [{ id: 1, name: 'Jack' }, { id: 2, name: 'John' }]
},
getUsersById: async (id) => {
await sleep(4);
const user = [{ id: 1, name: 'Jack' }, { id: 2, name: 'John' }].find(el => el.id === id);
if (user) {
return user
}
throw new Error('User Not Found')
},
}
export const { getAllUsers, getUsersById } = wrapFunctions(userService, 'userService')
userController.js
import { getUsersById, getAllUsers } from './userService.js';
import { wrapFunctions } from './utils.js';
const userController = {
getUser: async (id) => {
return getUsersById(id);
},
getAll : async () => {
return getAllUsers();
}
}
export const { getUser, getAll } = wrapFunctions(userController, 'userController')
index.js
import { getAll, getUser } from "./userController.js";
getAll().then(()=>{
getUser(1).then(()=> {
getUser(3)
})
})
//Prints
/*
Calling function [userController]->getAll with arguments: []
Calling function [userService]->getAllUsers with arguments: []
Result from function [userService]->getAllUsers: [ { id: 1, name: 'Jack' }, { id: 2, name: 'John' } ]
Result from function [userController]->getAll: [ { id: 1, name: 'Jack' }, { id: 2, name: 'John' } ]
Calling function [userController]->getUser with arguments: [ 1 ]
Calling function [userService]->getUsersById with arguments: [ 1 ]
Result from function [userService]->getUsersById: { id: 1, name: 'Jack' }
Result from function [userController]->getUser: { id: 1, name: 'Jack' }
Calling function [userController]->getUser with arguments: [ 3 ]
Calling function [userService]->getUsersById with arguments: [ 3 ]
Error in function [userService]->getUsersById: User Not Found
Error in function [userController]->getUser: User Not Found
*/
You got it right? I have used the infamous callback hell just to demonstrate the flow of logs. in real world use case you’ll have apis pointing to controller functions.
You don’t want it in production? No worries, put a flag in the wrapFunctionWithLogging function something like
if(process.env.NODE_ENV === ‘development‘){
//…execute with logs
}
As we have seen just 2 methods, this may look like over engineering, but when you codebase grows, this will help you a lot!
This snippet is available on
Happy Debugging!