configurable decorators for automated observability 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 clarity and debugging/maintainance experience.
/* 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';
class Handler
//...
@eventLogger()
@eventTimer()
userCancelSubscription = ({ userId }, meta, context)
|> UserProfileAPI.getSubscription
|> SubscriptionAPI.cancel
Thanks to the opionated function signature, those decorators work out of box with minimum configuration to create a calling stack tree using the exact names of the decorated functions, producing structured log, metrics, tracing.
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/debugging system.
[info] event: userCancelSubscription.getSubscription
[error] event: userCancelSubscription.cancelSubscription, type: TimeoutError, Retry: 1, Param: { subscriptionId: '4672c33a-ff0a-4a8c-8632-80aea3a1c1c1' }
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 { chain, eventLogger, eventTimer, errorRetry } from '@opbi/hooks';
import { UserProfileAPI, SubscriptionAPI } from './api.js';
const monitor = chain(eventLogger(), eventTimer());
const userCancelSubscription = ({ userId }, meta, context)
|> monitor(UserProfileAPI.getSubscription)
|> chain(
monitor,
errorRetry({ condition: e => e.type === 'TimeoutError' }), // step level control
)(SubscriptionAPI.cancel)
export default {
'userCancelSubscription': monitor(userCancelSubscription)
};
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.
With the decorator and pipe operators being enabled, we can easily turn the codebase into an illustration of business logic and technical behaviour.
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 problems, utilising everything modern JavaScript is offering and taking care of all possible pitafalls. For example, standard decorators need to be enhaced so that the name of the decoratee function can be passed correctly through the decorator chain. All those small details hidden in the corner have been well polished for you.
This high-quality suite draws the essence from its predecessor that has served a large-scale production system and is designed to empower your codebase with minimum effort.
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.
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 Handler from './handler.js';
app.delete('/subscription/:userId', Handler.userCancelSubscription);
Integration with Redux is TBC.