Skip to content

Commit

Permalink
Merge fcdb6ee into a044368
Browse files Browse the repository at this point in the history
  • Loading branch information
SimoneBergonzi committed Sep 21, 2022
2 parents a044368 + fcdb6ee commit bee1727
Show file tree
Hide file tree
Showing 8 changed files with 338 additions and 10 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Unreleased

### Added

- add optional metrics on request duration. The metrics collection is enabled by mean of the variable `ENABLE_HTTP_CLIENT_METRICS` (default: false)

### Added

- add property `duration` to the httpClient response

## v5.1.1 - 2022-07-21

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions docs/http_client.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,15 @@ All methods return a *Promise object*. You can access to:
* **Status code** of the response trough the `statusCode` property
* **Body** of the response trough the `payload` property
* **Headers** of the response trough the `headers` property
* **Duration** of the http call trough the `duration` property

If service responds with status code not valid (it is possible to modify this using the `validateStatus` ), the error object contains:

* **Message** of the response trough the `message` property (or it is possible to customize the key using the `errorMessageKey` option) if response is in JSON. Otherwise, it returns a default error message `Something went wrong`
* **Status code** of the response trough the `statusCode` property
* **Body** of the response trough the `payload` property
* **Headers** of the response trough the `headers` property
* **Duration** of the http call trough the `duration` property

If error is not an http error, it is throw the error message and the error code.

Expand Down
9 changes: 8 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,17 +122,23 @@ declare namespace customPlugin {
//
// HTTP CLIENT
//
type HttpClientMetrics = {
disabled: boolean
urlLabel: string
}
interface HttpClientBaseOptions {
headers?: http.IncomingHttpHeaders,
timeout?: number,
cert?: string,
key?: string,
ca?: string,
logger?: fastify.FastifyLoggerInstance
logger?: fastify.FastifyLoggerInstance,
disableMetrics?: boolean
}
interface BaseHttpClientResponse {
headers: http.IncomingHttpHeaders
statusCode: number
duration: number
}
interface StreamResponse extends BaseHttpClientResponse {
payload: NodeJS.ReadableStream
Expand Down Expand Up @@ -160,6 +166,7 @@ declare namespace customPlugin {
errorMessageKey?: string;
proxy?: HttpClientProxy;
query?: Record<string, string>;
metrics?: HttpClientMetrics;
}

type RequestBody = any | Buffer | ReadableStream
Expand Down
25 changes: 23 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const CLIENTTYPE_HEADER_KEY = 'CLIENTTYPE_HEADER_KEY'
const BACKOFFICE_HEADER_KEY = 'BACKOFFICE_HEADER_KEY'
const MICROSERVICE_GATEWAY_SERVICE_NAME = 'MICROSERVICE_GATEWAY_SERVICE_NAME'
const ADDITIONAL_HEADERS_TO_PROXY = 'ADDITIONAL_HEADERS_TO_PROXY'
const ENABLE_HTTP_CLIENT_METRICS = 'ENABLE_HTTP_CLIENT_METRICS'

const baseSchema = {
type: 'object',
Expand Down Expand Up @@ -85,6 +86,11 @@ const baseSchema = {
default: '',
description: 'comma separated list of additional headers to proxy',
},
[ENABLE_HTTP_CLIENT_METRICS]: {
type: 'boolean',
default: false,
description: 'flag to enable the httpClient metrics',
},
},
}

Expand Down Expand Up @@ -199,11 +205,11 @@ function getHttpClientFromRequest(url, baseOptions = {}) {
const extraHeaders = getCustomHeaders(extraHeadersKeys, requestHeaders)
const options = getBaseOptionsDecorated(this[ADDITIONAL_HEADERS_TO_PROXY], baseOptions, requestHeaders)
const serviceHeaders = { ...this.getMiaHeaders(), ...extraHeaders }
return new HttpClient(url, serviceHeaders, options)
return new HttpClient(url, serviceHeaders, options, this.httpClientMetrics)
}

function getHttpClient(url, baseOptions = {}) {
return new HttpClient(url, {}, baseOptions)
return new HttpClient(url, {}, baseOptions, this.httpClientMetrics)
}

function getHeadersToProxy({ isMiaHeaderInjected = true } = {}) {
Expand All @@ -221,6 +227,7 @@ function getHeadersToProxy({ isMiaHeaderInjected = true } = {}) {

function decorateFastify(fastify) {
const { config } = fastify
const httpClientMetrics = config[ENABLE_HTTP_CLIENT_METRICS] ? getHttpClientMetrics(fastify) : {}

fastify.decorateRequest(USERID_HEADER_KEY, config[USERID_HEADER_KEY])
fastify.decorateRequest(USER_PROPERTIES_HEADER_KEY, config[USER_PROPERTIES_HEADER_KEY])
Expand All @@ -241,6 +248,7 @@ function decorateFastify(fastify) {
fastify.decorateRequest('getDirectServiceProxy', getDirectlyServiceBuilderFromRequest)
fastify.decorateRequest('getServiceProxy', getServiceBuilderFromRequest)
fastify.decorateRequest('getHttpClient', getHttpClientFromRequest)
fastify.decorateRequest('httpClientMetrics', httpClientMetrics)

fastify.decorate(MICROSERVICE_GATEWAY_SERVICE_NAME, config[MICROSERVICE_GATEWAY_SERVICE_NAME])
fastify.decorate('addRawCustomPlugin', addRawCustomPlugin)
Expand All @@ -250,6 +258,7 @@ function decorateFastify(fastify) {
fastify.decorate('getDirectServiceProxy', getDirectlyServiceBuilderFromService)
fastify.decorate('getServiceProxy', getServiceBuilderFromService)
fastify.decorate('getHttpClient', getHttpClient)
fastify.decorate('httpClientMetrics', httpClientMetrics)
}

async function decorateRequestAndFastifyInstance(fastify, { asyncInitFunction, serviceOptions = {} }) {
Expand Down Expand Up @@ -313,6 +322,18 @@ function initCustomServiceEnvironment(envSchema = defaultSchema) {
}
}

function getHttpClientMetrics(fastify) {
if (fastify.metrics?.client) {
const requestDuration = new fastify.metrics.client.Histogram({
name: 'http_request_duration_milliseconds',
help: 'request duration histogram',
labelNames: ['baseUrl', 'url', 'method', 'statusCode'],
buckets: [5, 10, 50, 100, 500, 1000, 5000, 10000],
})
return { requestDuration }
}
}

module.exports = initCustomServiceEnvironment
module.exports.getDirectServiceProxy = getDirectlyServiceBuilderFromService
module.exports.getServiceProxy = (microserviceGatewayServiceName, baseOptions = {}) => {
Expand Down
58 changes: 57 additions & 1 deletion lib/httpClient.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-statements */
/*
* Copyright 2022 Mia srl
*
Expand All @@ -21,17 +22,19 @@ const httpsClient = require('https')
const Pino = require('pino')

class HttpClient {
constructor(baseUrl, requestHeadersToProxy, baseOptions = {}) {
constructor(baseUrl, requestHeadersToProxy, baseOptions = {}, metrics = {}) {
this.baseURL = baseUrl
this.requestHeadersToProxy = requestHeadersToProxy
this.baseOptions = baseOptions
this.metrics = baseOptions.disableMetrics ? {} : metrics

const httpsAgent = getHttpsAgent(baseOptions)
this.axios = axios.create({
...httpsAgent ? { httpsAgent } : {},
baseURL: baseUrl,
timeout: baseOptions.timeout,
})
decorateResponseWithDuration(this.axios)
}

async get(path, options = {}) {
Expand Down Expand Up @@ -86,6 +89,7 @@ class HttpClient {
const headers = getHeaders(options, this.requestHeadersToProxy, this.baseOptions, payload)
const httpsAgent = getHttpsAgent(options)
const errorMessageKey = getErrorMessageKey(options, this.baseOptions)
const metricsOptions = getMetricsOptions(options.metrics, url, this.metrics)
try {
const validateStatus = getValidateStatus(options)
logger.trace({ baseURL: this.baseURL, url, headers, payload }, 'make call')
Expand All @@ -106,8 +110,19 @@ class HttpClient {
statusCode: response.status,
headers: response.headers,
payload: response.data,
duration: response.duration,
}
logger.trace({ url, ...responseBody }, 'response info')
if (metricsOptions.enabled) {
this.metrics.requestDuration.observe({
method: options.method,
url: metricsOptions.urlLabel,
baseUrl: this.baseURL,
statusCode: response.status,
},
response.duration,
)
}
return responseBody
} catch (error) {
if (error.response) {
Expand All @@ -116,8 +131,19 @@ class HttpClient {
responseError.headers = error.response.headers
responseError.statusCode = error.response.status
responseError.payload = error.response.data
responseError.duration = error.duration
// eslint-disable-next-line id-blacklist
logger.error({ statusCode: error.response.status, message: errorMessage }, 'response error')
if (metricsOptions.enabled) {
this.metrics.requestDuration.observe({
method: options.method,
url: metricsOptions.urlLabel,
baseUrl: this.baseURL,
statusCode: error.response.status,
},
error.duration,
)
}
throw responseError
}
const errToReturn = new Error(error.message)
Expand Down Expand Up @@ -214,5 +240,35 @@ function getHeaders(options, miaHeaders, baseOptions, payload) {
}
}

function getMetricsOptions(metricsOptions = {}, url, metrics) {
return {
enabled: Boolean(metrics.requestDuration && metricsOptions.disabled !== true),
urlLabel: metricsOptions.urlLabel ?? url,
}
}

function decorateResponseWithDuration(axiosInstance) {
axiosInstance.interceptors.response.use(
(response) => {
response.config.metadata.endTime = new Date()
response.duration = response.config.metadata.endTime - response.config.metadata.startTime
return response
},
(error) => {
error.config.metadata.endTime = new Date()
error.duration = error.config.metadata.endTime - error.config.metadata.startTime
return Promise.reject(error)
}
)

axiosInstance.interceptors.request.use(
(config) => {
config.metadata = { startTime: new Date() }
return config
}, (error) => {
return Promise.reject(error)
})
}

module.exports = HttpClient

20 changes: 14 additions & 6 deletions tests/getHttpClient.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,22 @@ const MY_AWESOME_SERVICE_PROXY_HTTPS_URL = 'https://my-awesome-service'
const MY_AWESOME_SERVICE_PROXY_HTTP_URL_CUSTOM_PORT = 'http://my-awesome-service:3000'
const MY_AWESOME_SERVICE_PROXY_HTTPS_URL_CUSTOM_PORT = 'https://my-awesome-service:3001'

const fastifyMock = {
httpClientMetrics: {
requestDuration: {
observe: () => { /* no-op*/ },
},
},
}

tap.test('getHttpClient available for testing - complete url passed', async t => {
nock.disableNetConnect()
t.teardown(() => {
nock.enableNetConnect()
})

const RETURN_MESSAGE = 'OK'
const customProxy = getHttpClient(MY_AWESOME_SERVICE_PROXY_HTTP_URL)
const customProxy = getHttpClient.call(fastifyMock, MY_AWESOME_SERVICE_PROXY_HTTP_URL)
const awesomeHttpServiceScope = nock(`${MY_AWESOME_SERVICE_PROXY_HTTP_URL}:80`)
.get('/test-endpoint')
.reply(200, {
Expand All @@ -38,7 +46,7 @@ tap.test('getHttpClient available for testing - timeout passed', async t => {
})

const RETURN_MESSAGE = 'OK'
const customProxy = getHttpClient(MY_AWESOME_SERVICE_PROXY_HTTP_URL, {
const customProxy = getHttpClient.call(fastifyMock, MY_AWESOME_SERVICE_PROXY_HTTP_URL, {
timeout: 100,
})
const awesomeHttpServiceScope = nock(`${MY_AWESOME_SERVICE_PROXY_HTTP_URL}:80`)
Expand All @@ -65,7 +73,7 @@ tap.test('getHttpClient available for testing - https url', async t => {
})

const RETURN_MESSAGE = 'OK'
const customProxy = getHttpClient(MY_AWESOME_SERVICE_PROXY_HTTPS_URL)
const customProxy = getHttpClient.call(fastifyMock, MY_AWESOME_SERVICE_PROXY_HTTPS_URL)
const awesomeHttpsServiceScope = nock(`${MY_AWESOME_SERVICE_PROXY_HTTPS_URL}:443`)
.get('/test-endpoint')
.reply(200, {
Expand All @@ -86,7 +94,7 @@ tap.test('getHttpClient available for testing - custom port 3000 - custom header
})

const RETURN_MESSAGE = 'OK'
const customProxy = getHttpClient(MY_AWESOME_SERVICE_PROXY_HTTP_URL_CUSTOM_PORT,
const customProxy = getHttpClient.call(fastifyMock, MY_AWESOME_SERVICE_PROXY_HTTP_URL_CUSTOM_PORT,
{
headers: {
'test-header': 'test header works',
Expand All @@ -113,7 +121,7 @@ tap.test('getHttpClient available for testing - https url - custom port 3001', a
})

const RETURN_MESSAGE = 'OK'
const customProxy = getHttpClient(MY_AWESOME_SERVICE_PROXY_HTTPS_URL_CUSTOM_PORT)
const customProxy = getHttpClient.call(fastifyMock, MY_AWESOME_SERVICE_PROXY_HTTPS_URL_CUSTOM_PORT)
const awesomeHttpsServiceScope = nock(`${MY_AWESOME_SERVICE_PROXY_HTTPS_URL}:3001`)
.get('/test-endpoint')
.reply(200, {
Expand All @@ -130,7 +138,7 @@ tap.test('getHttpClient available for testing - https url - custom port 3001', a
tap.test('getHttpClient throws on invalid url', async t => {
const invalidUrl = 'httpnot-a-complete-url'
try {
getHttpClient(invalidUrl)
getHttpClient.call(fastifyMock, invalidUrl)
} catch (error) {
t.notOk(true, 'The function should not throw anymore if the url is not a valid one, bet return the standard proxy')
}
Expand Down
Loading

0 comments on commit bee1727

Please sign in to comment.