opinionated decorator suite for easy cross-project observability consistency and self-explanatory codebase
By packing all standardisable patterns such as observarability, error handling, etc. as reusable decorators, it promotes and ensures consistency across micro-services and teams. This greatly improves the monitor efficiency and debugging or maintainance experience.
It is a very simple package and many companies probably have similar ones built in-house, while this package aims at providing the most maintainable, concise and universal solution to the common problem, utilising everything modern JavaScript is offering.
/* api.js - common behaviour can be declared by decorators */
class UserProfileAPI
//...
@eventLogger()
@eventTimer()
getSubscription({ userId }):
//...
class SubscriptionAPI
//...
@eventLogger()
@eventTimer()
@errorRetry({ condition: e => e.type === 'TimeoutError' })
cancel({ SubscriptionId }):
//...
/* handler.js - an illustration of the business logic */
import { UserProfileAPI, SubscriptionAPI } from './api.js';
/**
@param {string} param.userId
**/
export const userCancelSubscription = ({ userId }, meta, context)
|> UserProfileAPI.getSubscription
|> SubscriptionAPI.cancel
Those decorators work out of box with minimum configuration thanks to the opionated function signature.
The structured log it produced below makes it a breeze to precisely pinpoint the error function with param to reproduce the case. This can be easily further integrated into an automated cross-team monitoring and alerting system.
[info] event: userCancelSubscription.getSubscription
[error] event: userCancelSubscription.cancelSubscription, type: TimeoutError, Retry: 1, Param: { subscriptionId: '4672c33a-ff0a-4a8c-8632-80aea3a1c1c1' }
With the decorator and pipe operators being enabled, we can easily turn the codebase into an illustration of business logic and technical behaviour. They also work greatly without the operators.
By abstract out all common control mechanism and observability code into well-tested, composable decorators, this also helps to achieve codebase that is self-explanatory of its business logic and technical behaviour by the names of functions and decorators. This is great for testing and potentially rewrite the entire business logic functions as anything other than business logic is being packed into well-tested reusable decorators, which can be handily mocked during test.
yarn add @opbi/hooks
All the hooks come with default configuration.
errorRetry()(stepFunction)
Descriptive names of configured hooks help to make the behaviour self-explanatory.
const errorRetryOnTimeout = errorRetry({ condition: e => e.type === 'TimeoutError' })
Patterns composed of configured hooks can easily be reused.
const monitor = chain(eventLogger(), eventTimer(), eventTracer());
"The order of the hooks in the chain matters."
Check the automated doc page for the available hooks in the current ecosystem.
Hooks are named in a convention to reveal where and how it works
[hook point][what it is/does]
, e.g. errorCounter, eventLogger. Hook points are namedbefore, after, error
andevent
(multiple points).
You can easily create more standardised hooks with addHooks helper. Open source them aligning with the above standards via pull requests or individual packages are highly encouraged.
Standardisation of function signature is powerful that it creates predictable value flows throughout the functions and hooks chain, making functions more friendly to meta-programming. Moreover, it is also now a best-practice to use object destruct assign for key named parameters.
Via exploration and the development of hooks, we set a function signature standard to define the order of different kinds of variables as expected and we call it action function
:
/**
* The standard function signature.
* @param {object} param - parameters input to the function
* @param {object} meta - metadata tagged for function observability(logger, metrics), e.g. requestId
* @param {object} context - contextual callable instances or unrecorded metadata, e.g. logger, req
*/
function (param, meta, context) {}
To help adopting the hooks by testing them out with minimal refactor on non-standard signature functions, there's an unreleased adaptor to bridge the function signatures. It is not recommended to use this for anything but trying the hooks out, especially observability hooks are not utilised this way.
We are calling those decorators hooks(decorators at call-time beside definition-time) to indicate that they can be used at any point of a business logic function lifecycle to extend highly flexible and precise control.
/* handler.js - configure and attach hooks to business logic steps with hookEachPipe */
import { pipeHookEach, eventLogger, eventTimer } from '@opbi/hooks';
import { UserProfileAPI, SubscriptionAPI } from './api.js';
export const userCancelSubscription = async pipeHookEach(
// all with default configuration applied to each step below
eventLogger(), eventTimer()
)(
UserProfileAPI.getSubscription,
// precise control with step only hook
chain(
errorRetry({ condition: e => e.type === 'TimeoutError' })
)(SubscriptionAPI.cancel),
);
Those hook enhanced functions can be seemlessly plugged into server frameworks with the adaptor provided, e.g. Express.
/* app.js - setup logger, metrics and adapt the express router to use hook signature */
import express from 'express';
import logger, metrics from '@opbi/toolchain';
import { adaptorExpress } from '@opbi/hooks';
export default adaptorExpress(express, { logger, metrics });
/* router.js - use the handler with automated logger, metrics */
import app from './app.js';
import { userCancelSubscription } from './handler.js';
app.delete('/subscription/:userId', userCancelSubscription);
Integration with Redux is TBC.