@biorate/axios-prometheus
Version:
Axios-prometheus HTTP interface
257 lines (240 loc) • 7.82 kB
text/typescript
// noinspection TypeScriptUnresolvedVariable
import { readFileSync, writeFileSync, statSync, mkdirSync } from 'fs';
import { path, time as timeTools } from '@biorate/tools';
import { container, Types } from '@biorate/inversion';
import { IConfig } from '@biorate/config';
import { Axios, AxiosError, AxiosResponse, IAxiosFetchOptions } from '@biorate/axios';
import { counter, Counter, histogram, Histogram } from '@biorate/prometheus';
import { trace, Span } from '@biorate/opentelemetry';
import { get, set, pick } from 'lodash';
export * from '@biorate/axios';
/**
* @description
* Axios-prometheus HTTP interface
*
* ### Features:
* - Metrics of you HTTP requests out of the box
*
* @example
* ```
* import { container, Types } from '@biorate/inversion';
* import { IConfig, Config } from '@biorate/config';
* import { AxiosPrometheus } from '@biorate/axios-prometheus';
*
* export class Google extends AxiosPrometheus {
* public baseURL = this.config.get<string>('baseURL');
* public url = '/';
* public method = 'get';
* public timeout = 1500;
* }
*
* container.bind(Types.Config).to(Config).inSingletonScope();
*
* container.get<IConfig>(Types.Config).merge({
* baseURL: 'https://google.com',
* });
*
* (async () => {
* await Google.fetch();
* })();
* ```
*/
export abstract class AxiosPrometheus extends Axios {
protected static mockFields = ['data', 'status', 'statusText'];
protected static mockFileName(name: string) {
return `Axios.${name}.snap`;
}
protected static checkMockDir(directory: string) {
try {
const dir = path.create(process.cwd(), directory);
const stats = statSync(dir);
if (!stats.isDirectory()) return null;
return dir;
} catch {
return null;
}
}
protected static mockFilePath(filename?: string) {
let directory = (<IConfig>container.get<IConfig>(Types.Config)).get<string | null>(
'axios.mock.directory',
null,
);
if (!directory) directory = this.checkMockDir('test');
if (!directory) directory = this.checkMockDir('tests');
if (!directory) directory = process.cwd();
return path.create(directory, '__snapshots__', filename ?? '');
}
protected static getMockData(instance: Axios, filename: string) {
return JSON.parse(readFileSync(this.mockFilePath(filename), 'utf8'));
}
protected static getMock<T = any, D = any>(
instance: Axios,
options?: IAxiosFetchOptions,
): undefined | AxiosResponse<T, D> {
const filename = this.mockFileName(instance.constructor.name);
try {
return get(
this.getMockData(instance, filename),
`${instance.constructor.name}.${JSON.stringify(options)}`,
);
} catch (e) {
console.warn(
`Axios mock snap file [${filename}] doesn't exists, or corrupted., because of [${
(<Error>e)?.message
}]`,
);
}
}
protected static setMock<T = any, D = any>(
instance: Axios,
result: AxiosResponse<T, D>,
options?: IAxiosFetchOptions,
) {
let data: Record<string, unknown>;
const filename = this.mockFileName(instance.constructor.name);
try {
data = this.getMockData(instance, filename);
} catch {
data = {};
}
set(
data,
`${instance.constructor.name}.${JSON.stringify(options)}`,
pick(result, ...this.mockFields),
);
try {
mkdirSync(this.mockFilePath(), { recursive: true });
} catch {}
try {
writeFileSync(
this.mockFilePath(filename),
JSON.stringify(data, null, ' '),
'utf8',
);
} catch (e) {
console.warn(
`Can't write Axios mock snap file [${filename}], because of [${
(<Error>e)?.message
}]`,
);
}
}
protected get config() {
return container.get<IConfig>(Types.Config);
}
({
name: 'http_client_requests_seconds_count',
help: 'Http client requests count',
labelNames: ['method', 'uri', 'status'],
})
protected counter: Counter;
({
name: 'http_client_requests_seconds',
help: 'Http client requests seconds bucket',
labelNames: ['method', 'uri', 'status'],
buckets: [0.005, 0.01, 0.02, 0.05, 0.1, 0.3, 0.5, 1, 2, 3, 5, 10],
})
protected histogram: Histogram;
public abstract baseURL: string;
public abstract url: string;
public abstract method: string;
/**
* @description Get start time method
*/
protected getStartTime(): [number, number] {
return process.hrtime();
}
/**
* @description Log method
*/
protected log(statusCode: number, startTime: [number, number]) {
const diff = process.hrtime(startTime);
const time = diff[0] * 1e3 + diff[1] * 1e-6;
const msTo = timeTools.msTo;
this.counter
.labels({
method: this.method,
uri: this.baseURL + this.url,
status: statusCode,
})
.inc();
this.histogram
.labels({
method: this.method,
uri: this.baseURL + this.url,
status: statusCode,
})
.observe(msTo(time, 's'));
}
/**
* @description Stringify data
*/
protected stringify(data: unknown) {
return typeof data === 'object' ? JSON.stringify(data) : String(data);
}
/**
* @description Check need to trace
*/
protected needTrace(url: string, span?: Span) {
if (!span) return false;
const excluded = this.config.get<(string | RegExp)[]>(
'AxiosPrometheus.tracing.excluded',
[],
);
for (const item of excluded) {
if (typeof item === 'string' && url.startsWith(item)) return false;
if (item instanceof RegExp && item.test(url)) return false;
}
return true;
}
/**
* @description Get full url = baseURL + url
*/
protected fullUrl(params?: IAxiosFetchOptions) {
return params?.baseURL ?? '' + params?.url ?? '';
}
protected async before(params?: IAxiosFetchOptions) {
await super.before(params);
const span = trace.getActiveSpan();
const url = this.fullUrl(params);
if (!this.needTrace(url, span)) return;
span!.setAttribute('outgoing.request.url', this.stringify(url));
span!.setAttribute('outgoing.request.body', this.stringify(params?.data));
span!.setAttribute('outgoing.request.headers', this.stringify(params?.headers));
span!.setAttribute('outgoing.request.method', this.stringify(params?.method));
span!.setAttribute('outgoing.request.params', this.stringify(params?.path));
span!.setAttribute('outgoing.request.query', this.stringify(params?.params));
}
protected async after(
result: AxiosResponse,
startTime: [number, number],
params: IAxiosFetchOptions,
) {
await super.after(result, startTime, params);
this.log(result.status, startTime);
const span = trace.getActiveSpan();
const url = this.fullUrl(params);
if (!this.needTrace(url, span)) return;
span!.setAttribute('outgoing.response.headers', this.stringify(result.headers));
span!.setAttribute('outgoing.response.statusCode', this.stringify(result.status));
span!.setAttribute('outgoing.response.data', this.stringify(result.data));
}
protected async catch(
e: Error | AxiosError,
startTime: [number, number],
params: IAxiosFetchOptions,
) {
await super.catch(e, startTime, params);
if (!('response' in e)) return;
this.log(e!.response!.status, startTime);
const span = trace.getActiveSpan();
const url = this.fullUrl(params);
if (!this.needTrace(url, span)) return;
span!.setAttribute('outgoing.response.headers', this.stringify(e!.response!.headers));
span!.setAttribute(
'outgoing.response.statusCode',
this.stringify(e!.response!.status),
);
span!.setAttribute('outgoing.response.data', this.stringify(e!.response!.data));
}
}