@villedemontreal/correlation-id
Version:
Express middleware to set a correlation in Express. The correlation id will be consistent across async calls within the handling of a request.
257 lines (224 loc) • 7.32 kB
text/typescript
import { AsyncLocalStorage } from 'async_hooks';
import * as cls from 'cls-hooked';
import { EventEmitter } from 'events';
import * as express from 'express';
import * as semver from 'semver';
import { v4 as uuid } from 'uuid';
import { constants } from '../config/constants';
const oldEmitSlot = Symbol('kOriginalEmit');
const cidSlot = Symbol('kCorrelationId');
const storeSlot = Symbol('kCidStore');
/**
* CorrelationId type
*/
export type CorrelationId = string;
/**
* Informations about the correlation ID.
*/
export interface ICidInfo {
/**
* Current cid
*/
current: string;
/**
* Cid received in the request (may be undefined)
*/
receivedInRequest: string;
/**
* Cid generated (may be undefined)
*/
generated: string;
}
/**
* CorrelationId service
*/
export interface ICorrelationIdService {
/**
* Creates a new correlation ID that can then be passed to the
* "withId()" function.
*/
createNewId(): CorrelationId;
/**
* Executes a function inside a context where the correlation ID is defined.
*
* @param work the function to run within the cid context.
* @param cid the correlation ID to use.
*
*/
withId<T>(work: () => T, cid?: CorrelationId): T;
/**
* Executes a function inside a context where the correlation ID is defined.
* This is the promisified version of the `withId` method.
* @param work a callback to invoke with the submitted correlation ID
* @param cid the correlation ID to install be before invoking the submitted callback
*
* @deprecated `#withId` is preferable instead: if the wrapped operation
* is asynchronous it will still be properly scoped (correlation context) and
* can safely be awaited for outside of `#withId`.
*/
withIdAsync<T>(work: () => Promise<T>, cid?: string): Promise<T>;
/**
* binds the current correlation context to the target
* @param target the target to bind to
* @returns either the submitted target (if it is an emitter) or a wrapped target (for a function)
* @remarks you might have to bind to an emitter in order to maitain
* the correlation context.
*/
bind<T>(target: T): T;
/**
* Returns the correlation ID from the current context
*/
getId(): CorrelationId;
/**
* Returns all correlation ID info
*/
getCidInfo(req: express.Request): ICidInfo;
}
/**
* CorrelationId service
*/
class CorrelationIdServiceWithClsHooked implements ICorrelationIdService {
private store: cls.Namespace = cls.createNamespace('343c9880-fa2b-4212-a9f3-15f3cc09581d');
public createNewId(): CorrelationId {
return uuid();
}
public withId<T>(work: () => T, cid?: CorrelationId): T {
let cidClean = cid;
if (!cidClean) {
cidClean = this.createNewId();
}
return this.store.runAndReturn(() => {
this.store.set('correlator', cidClean);
return work();
});
}
public async withIdAsync<T>(work: () => Promise<T>, cid?: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.withId(() => {
try {
work().then(resolve).catch(reject);
} catch (err) {
reject(err);
}
}, cid);
});
}
public bind<T>(target: T): T {
if (target instanceof EventEmitter) {
return this.bindEmitter(target);
}
if (typeof target === 'function') {
return this.bindFunction(target);
}
return target;
}
public getId() {
return this.store.get('correlator');
}
public getCidInfo(req: express.Request): ICidInfo {
return {
current: this.getId(),
receivedInRequest: (req as any)[constants.requestExtraVariables.cidReceivedInRequest],
generated: (req as any)[constants.requestExtraVariables.cidNew],
};
}
private bindEmitter<T extends EventEmitter>(emitter: T): T {
// Note that we can't use the following line:
// this.store.bindEmitter(emitter);
// because this works only if bindEmitter is called before any
// call to the "on" method of the emitter, and I don't want to
// risk having ordering issues.
// Note however that patching an emitter might not work in 100% cases.
// patch emit method only once!
const emitterObj: any = emitter;
if (!emitterObj[oldEmitSlot]) {
emitterObj[oldEmitSlot] = emitter.emit;
(emitter as any).emit = (...args: any[]) => {
// wrap the emit call within a new correlation context
// with the bound cid.
this.withId(() => {
// invoke original emit method
emitterObj[oldEmitSlot].apply(emitter, args);
}, emitterObj[cidSlot]);
};
}
// update the cid bound to the emitter
emitterObj[cidSlot] = this.getId();
return emitter;
}
private bindFunction<T extends Function>(target: T): T {
return this.store.bind(target);
}
}
interface AsyncLocalStorageData {
correlationId?: string;
}
class CorrelationIdServiceWithAsyncLocalStorage implements ICorrelationIdService {
private storage = new AsyncLocalStorage<AsyncLocalStorageData>();
public createNewId(): CorrelationId {
return uuid();
}
public withId<T>(work: () => T, cid?: CorrelationId): T {
const correlationId = cid || this.createNewId();
return this.storage.run({ correlationId }, work);
}
public async withIdAsync<T>(work: () => Promise<T>, cid?: string): Promise<T> {
return this.withId(work, cid);
}
public bind<T>(target: T): T {
if (target instanceof EventEmitter) {
return this.bindEmitter(target);
}
if (typeof target === 'function') {
return this.bindFunction(target);
}
return target;
}
public getId() {
const store = this.storage.getStore();
if (store) {
return store.correlationId;
}
return undefined;
}
public getCidInfo(req: express.Request): ICidInfo {
return {
current: this.getId(),
receivedInRequest: (req as any)[constants.requestExtraVariables.cidReceivedInRequest],
generated: (req as any)[constants.requestExtraVariables.cidNew],
};
}
private bindEmitter<T extends EventEmitter>(emitter: T): T {
// patch emit method only once!
const emitterObj: any = emitter;
if (!emitterObj[oldEmitSlot]) {
emitterObj[oldEmitSlot] = emitter.emit;
emitterObj.emit = (...args: any[]) => {
// use the store that was bound to this emitter
const store = emitterObj[storeSlot];
if (store) {
this.storage.enterWith(store);
}
// invoke original emit method
emitterObj[oldEmitSlot].call(emitter, ...args);
};
}
// update the store bound to the emitter
emitterObj[storeSlot] = this.storage.getStore();
return emitter;
}
private bindFunction<T extends Function>(target: T): T {
const storage = this.storage;
const store = this.storage.getStore();
return function (...args: any[]) {
storage.enterWith(store);
return target.call(this, ...args);
} as any;
}
}
function canUseAsyncLocalStorage() {
return semver.satisfies(process.versions.node, '>=13.10.0') && !!AsyncLocalStorage;
}
export const correlationIdService: ICorrelationIdService = canUseAsyncLocalStorage()
? new CorrelationIdServiceWithAsyncLocalStorage()
: new CorrelationIdServiceWithClsHooked();