UNPKG

@biorate/axios-prometheus

Version:

Axios-prometheus HTTP interface

257 lines (240 loc) 7.82 kB
// 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); } @counter({ name: 'http_client_requests_seconds_count', help: 'Http client requests count', labelNames: ['method', 'uri', 'status'], }) protected counter: Counter; @histogram({ 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)); } }