@mojaloop/central-services-shared 18.35.3 → 18.36.0-infitx.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mojaloop/central-services-shared",
3
- "version": "18.35.3",
3
+ "version": "18.36.0-infitx.1",
4
4
  "description": "Shared code for mojaloop central services",
5
5
  "license": "Apache-2.0",
6
6
  "author": "ModusBox",
@@ -48,6 +48,7 @@
48
48
  "test:endpoints": "npx tape 'test/unit/util/endpoints.test.js'",
49
49
  "test:mysql": "npx tape 'test/unit/mysql/**/*.test.js'",
50
50
  "test:participants": "npx tape 'test/unit/util/participants.test.js'",
51
+ "test:request": "npx tape 'test/unit/util/request.test.js'",
51
52
  "test:trans": "npx tape 'test/unit/util/headers/transformer.test.js'",
52
53
  "test:unit": "npx tape 'test/unit/**/*.test.js' | tap-spec",
53
54
  "test:xunit": "npx tape 'test/unit/**/**.test.js' | tap-xunit > ./test/results/xunit.xml",
@@ -71,12 +72,13 @@
71
72
  "@hapi/joi-date": "2.0.1",
72
73
  "@mojaloop/inter-scheme-proxy-cache-lib": "2.9.0",
73
74
  "@opentelemetry/api": "1.9.0",
75
+ "@opentelemetry/semantic-conventions": "1.39.0",
74
76
  "async-exit-hook": "2.0.1",
75
77
  "async-retry": "1.3.3",
76
- "axios": "1.13.4",
78
+ "axios": "1.13.5",
77
79
  "clone": "2.1.2",
78
80
  "convict": "^6.2.4",
79
- "dotenv": "17.2.3",
81
+ "dotenv": "17.3.1",
80
82
  "env-var": "7.5.0",
81
83
  "event-stream": "4.0.1",
82
84
  "fast-safe-stringify": "2.1.1",
@@ -96,14 +98,14 @@
96
98
  "yaml": "2.8.2"
97
99
  },
98
100
  "devDependencies": {
99
- "@mojaloop/central-services-error-handling": "13.1.5",
100
- "@mojaloop/central-services-logger": "11.10.3",
101
- "@mojaloop/central-services-metrics": "12.8.3",
101
+ "@mojaloop/central-services-error-handling": "13.1.6",
102
+ "@mojaloop/central-services-logger": "11.11.0-infitx.0",
103
+ "@mojaloop/central-services-metrics": "12.8.4",
102
104
  "@mojaloop/event-sdk": "14.8.2",
103
- "@mojaloop/sdk-standard-components": "19.18.6",
105
+ "@mojaloop/sdk-standard-components": "19.18.7",
104
106
  "@opentelemetry/auto-instrumentations-node": "^0.69.0",
105
107
  "@types/hapi__joi": "17.1.15",
106
- "ajv": "^8.17.1",
108
+ "ajv": "^8.18.0",
107
109
  "ajv-formats": "^3.0.1",
108
110
  "ajv-keywords": "^5.1.0",
109
111
  "audit-ci": "7.1.0",
@@ -125,7 +127,8 @@
125
127
  "tapes": "4.1.0"
126
128
  },
127
129
  "overrides": {
128
- "axios": "1.13.4",
130
+ "@mojaloop/central-services-logger": "$@mojaloop/central-services-logger",
131
+ "axios": "1.13.5",
129
132
  "qs": "6.14.1",
130
133
  "brace-expansion": "2.0.2",
131
134
  "form-data": "4.0.4",
package/src/config.js CHANGED
@@ -9,6 +9,13 @@ const config = convict({
9
9
  env: 'SHARED_CACHE_LOG_LEVEL'
10
10
  },
11
11
 
12
+ httpLogLevel: {
13
+ doc: 'Log level for HTTP wrapper.',
14
+ format: logLevelValues,
15
+ default: logLevelsMap.warn,
16
+ env: 'LOG_LEVEL_HTTP'
17
+ },
18
+
12
19
  defaultTtlSec: {
13
20
  doc: 'Default cache TTL.',
14
21
  format: Number,
package/src/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Utils as HapiUtil, Server } from '@hapi/hapi'
2
2
  import { ILogger } from '@mojaloop/central-services-logger/src/contextLogger'
3
3
  import { Knex } from 'knex';
4
+ import { AxiosRequestConfig, AxiosResponse, ResponseType as AxiosResponseType } from 'axios'
4
5
  import IORedis from 'ioredis';
5
6
 
6
7
  declare namespace CentralServicesShared {
@@ -763,9 +764,27 @@ declare namespace CentralServicesShared {
763
764
  accept: string
764
765
  }
765
766
 
766
- type RequestParams = { url: string, headers: HapiUtil.Dictionary<string>, source: string, destination: string, hubNameRegex: RegExp, method?: RestMethodsEnum, payload?: any, responseType?: string, span?: any, jwsSigner?: any, protocolVersions?: ProtocolVersionsType }
767
- interface Request {
768
- sendRequest(params: RequestParams): Promise<any>
767
+ type RequestParams = {
768
+ url: string,
769
+ headers: HapiUtil.Dictionary<string>,
770
+ source: string,
771
+ destination?: string,
772
+ hubNameRegex: RegExp,
773
+ method?: RestMethodsEnum,
774
+ payload?: any,
775
+ params?: AxiosRequestConfig['params'],
776
+ responseType?: AxiosResponseType,
777
+ span?: any,
778
+ jwsSigner?: any,
779
+ protocolVersions?: ProtocolVersionsType,
780
+ apiType?: ApiTypeValues,
781
+ axiosRequestOptionsOverride?: Partial<AxiosRequestConfig>,
782
+ logger?: ILogger,
783
+ peerService?: string
784
+ }
785
+ export interface Request {
786
+ sendRequest(params: RequestParams): Promise<AxiosResponse>
787
+ sendBaseRequest(params?: AxiosRequestConfig & { logger?: ILogger, peerService?: string }): Promise<AxiosResponse>
769
788
  }
770
789
 
771
790
  interface Kafka {
@@ -30,6 +30,7 @@
30
30
  const { env } = require('node:process')
31
31
  const { asyncStorage } = require('@mojaloop/central-services-logger/src/contextLogger')
32
32
  const { logger } = require('../../../logger')
33
+ const { incomingRequestAttributesDto } = require('../../otelDto')
33
34
 
34
35
  const INTERNAL_ROUTES = env.LOG_INTERNAL_ROUTES ? env.LOG_INTERNAL_ROUTES.split(',') : ['/health', '/metrics', '/live']
35
36
  const TRACE_ID_HEADER = env.LOG_TRACE_ID_HEADER ?? 'traceid'
@@ -61,13 +62,11 @@ const loggingPlugin = {
61
62
  server.ext({
62
63
  type: 'onRequest',
63
64
  method: (request, h) => {
64
- const { path, method, headers, payload, query } = request
65
- const { remoteAddress } = request.info
66
- const requestId = request.info.id = `${request.info.id}__${headers[traceIdHeader]}`
65
+ const requestId = request.info.id = `${request.info.id}__${request.headers[traceIdHeader]}`
67
66
  asyncStorage.enterWith({ requestId })
68
67
 
69
- if (shouldLog(path)) {
70
- log.info(`[==> req] ${method.toUpperCase()} ${path}`, { headers, payload, query, remoteAddress })
68
+ if (shouldLog(request.path)) {
69
+ logRequest(request, log)
71
70
  }
72
71
  return h.continue
73
72
  }
@@ -77,16 +76,7 @@ const loggingPlugin = {
77
76
  type: 'onPreResponse',
78
77
  method: (request, h) => {
79
78
  if (shouldLog(request.path)) {
80
- const { path, method, payload, response } = request
81
- const { received } = request.info
82
-
83
- const statusCode = response instanceof Error
84
- ? response.output?.statusCode
85
- : response.statusCode
86
- const { output } = response
87
- const respTimeSec = ((Date.now() - received) / 1000).toFixed(1)
88
-
89
- log.info(`[<== ${statusCode}] ${method.toUpperCase()} ${path} [${respTimeSec} sec]`, { payload, output })
79
+ logResponse(request, log)
90
80
  }
91
81
  return h.continue
92
82
  }
@@ -94,4 +84,69 @@ const loggingPlugin = {
94
84
  }
95
85
  }
96
86
 
87
+ /**
88
+ * @param {import('@hapi/hapi').Request} request
89
+ * @param {ILogger} log
90
+ * @returns OTelAttributes
91
+ */
92
+ const logRequest = (request, log) => {
93
+ log.info(`[==> req] ${request.method.toUpperCase()} ${request.path} `, {
94
+ headers: extractHeadersForLogs(request.headers),
95
+ // payload is not parsed yet
96
+ ...extractAttributes({ request })
97
+ })
98
+ }
99
+
100
+ /**
101
+ * @param {import('@hapi/hapi').Request} request
102
+ * @param {ILogger} log
103
+ * @returns OTelAttributes
104
+ */
105
+ const logResponse = (request, log) => {
106
+ const { method, path, response } = request
107
+
108
+ const statusCode = response instanceof Error
109
+ ? response.output?.statusCode
110
+ : response?.statusCode
111
+
112
+ const errorType = response instanceof Error
113
+ ? response.output?.payload?.error
114
+ : undefined
115
+
116
+ const durationSec = (Date.now() - request.info.received) / 1000
117
+
118
+ log.info(`[<== ${statusCode}] ${method.toUpperCase()} ${path} [${durationSec} s]`, {
119
+ headers: extractHeadersForLogs(response?.output?.headers),
120
+ payload: response?.output?.payload, // think if we need to log payload only with debug severity
121
+ ...extractAttributes({ request, durationSec, statusCode, errorType })
122
+ })
123
+ }
124
+
125
+ const extractAttributes = ({ request, durationSec, statusCode, errorType }) => {
126
+ return incomingRequestAttributesDto({
127
+ method: request.method,
128
+ path: request.path,
129
+ url: getFullUrl(request),
130
+ route: request.route.path,
131
+ serverAddress: request.info.hostname,
132
+ clientAddress: request.info.remoteAddress,
133
+ userAgent: request.headers['user-agent'],
134
+ requestId: request.info.id,
135
+ durationSec,
136
+ statusCode,
137
+ errorType
138
+ })
139
+ }
140
+
141
+ /** @param {import('@hapi/hapi').Request} req */
142
+ const getFullUrl = (req) => {
143
+ const search = req.url?.search || ''
144
+ return `${req.server.info.protocol}://${req.info.host}${req.path}${search}`
145
+ }
146
+
147
+ const extractHeadersForLogs = (headers = {}) => {
148
+ // todo: add impl.
149
+ return headers
150
+ }
151
+
97
152
  module.exports = loggingPlugin
@@ -0,0 +1,75 @@
1
+ /*****
2
+ License
3
+ --------------
4
+ Copyright © 2020-2025 Mojaloop Foundation
5
+ The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
10
+
11
+ Contributors
12
+ --------------
13
+ This is the official list of the Mojaloop project contributors for this file.
14
+ Names of the original copyright holders (individuals or organizations)
15
+ should be listed with a '*' in the first column. People who have
16
+ contributed from an organization can be listed under the organization
17
+ that actually holds the copyright for their contributions (see the
18
+ Mojaloop Foundation for an example). Those individuals should have
19
+ their names indented and be marked with a '-'. Email address can be added
20
+ optionally within square brackets <email>.
21
+
22
+ * Mojaloop Foundation
23
+ * Eugen Klymniuk <eugen.klymniuk@infitx.com>
24
+
25
+ --------------
26
+ ******/
27
+ /* istanbul ignore file */
28
+
29
+ const otel = require('@opentelemetry/semantic-conventions')
30
+
31
+ const ATTR_SERVICE_PEER_NAME = 'service.peer.name' // using string literal because ATTR_SERVICE_PEER_NAME is only available in @opentelemetry/semantic-conventions/incubating as of now
32
+ const CUSTOM_REQUEST_ID = 'request.id'
33
+
34
+ /** @typedef { attributes: Record<string, any> } OTelAttributes */
35
+
36
+ /** @returns OTelAttributes */
37
+ const outgoingRequestAttributesDto = ({
38
+ method, url, durationSec, statusCode, errorType, peerService
39
+ }) => ({
40
+ attributes: {
41
+ [otel.ATTR_HTTP_REQUEST_METHOD]: method,
42
+ [otel.ATTR_URL_FULL]: url,
43
+ [otel.METRIC_HTTP_CLIENT_REQUEST_DURATION]: durationSec, // 'duration.ms' is a custom attribute
44
+ ...(statusCode && { [otel.ATTR_HTTP_RESPONSE_STATUS_CODE]: statusCode }),
45
+ ...(errorType && { [otel.ATTR_ERROR_TYPE]: errorType }),
46
+ ...(peerService && { [ATTR_SERVICE_PEER_NAME]: peerService })
47
+ // peerService - logical service name, must be explicitly provided by caller (not derived from URL hostname)
48
+ // think if we should extract it for internal http://... calls from url hostname
49
+ }
50
+ })
51
+
52
+ /** @returns OTelAttributes */
53
+ const incomingRequestAttributesDto = ({
54
+ method, url, path, route,
55
+ serverAddress, clientAddress, userAgent, durationSec, requestId, statusCode, errorType
56
+ }) => ({
57
+ attributes: {
58
+ [otel.ATTR_HTTP_REQUEST_METHOD]: method,
59
+ [otel.ATTR_URL_FULL]: url,
60
+ [otel.ATTR_URL_PATH]: path,
61
+ [otel.ATTR_HTTP_ROUTE]: route,
62
+ [otel.ATTR_SERVER_ADDRESS]: serverAddress,
63
+ [otel.ATTR_CLIENT_ADDRESS]: clientAddress,
64
+ [otel.ATTR_USER_AGENT_ORIGINAL]: userAgent,
65
+ [CUSTOM_REQUEST_ID]: requestId,
66
+ ...(durationSec && { [otel.METRIC_HTTP_SERVER_REQUEST_DURATION]: durationSec }), // 'duration.ms' is a custom attribute
67
+ ...(statusCode && { [otel.ATTR_HTTP_RESPONSE_STATUS_CODE]: statusCode }),
68
+ ...(errorType && { [otel.ATTR_ERROR_TYPE]: errorType })
69
+ }
70
+ })
71
+
72
+ module.exports = {
73
+ outgoingRequestAttributesDto,
74
+ incomingRequestAttributesDto
75
+ }
@@ -30,29 +30,31 @@
30
30
  'use strict'
31
31
 
32
32
  const http = require('node:http')
33
- const request = require('axios')
33
+ const axios = require('axios')
34
34
  const stringify = require('fast-safe-stringify')
35
35
  const EventSdk = require('@mojaloop/event-sdk')
36
36
  const ErrorHandler = require('@mojaloop/central-services-error-handling')
37
37
  const Metrics = require('@mojaloop/central-services-metrics')
38
- const Headers = require('./headers/transformer')
39
- const enums = require('../enums')
40
- const { logger } = require('../logger')
38
+
39
+ const { logger: globalLogger } = require('../logger')
41
40
  const { API_TYPES } = require('../constants')
42
41
  const config = require('../config')
42
+ const enums = require('../enums')
43
+ const Headers = require('./headers/transformer')
44
+ const { outgoingRequestAttributesDto } = require('./otelDto')
43
45
 
44
46
  const MISSING_FUNCTION_PARAMETERS = 'Missing parameters for function'
45
47
 
46
48
  // Delete the default headers that the `axios` module inserts as they can brake our conventions.
47
49
  // By default it would insert `"Accept":"application/json, text/plain, */*"`.
48
- delete request.defaults.headers.common.Accept
50
+ delete axios.defaults.headers.common.Accept
49
51
 
50
52
  const keepAlive = (process.env.HTTP_AGENT_KEEP_ALIVE ?? 'true') === 'true'
51
- logger.verbose('http keepAlive:', { keepAlive })
53
+ globalLogger.verbose('http keepAlive:', { keepAlive })
52
54
 
53
55
  // Enable keepalive for http
54
- request.defaults.httpAgent = new http.Agent({ keepAlive })
55
- request.defaults.httpAgent.toJSON = () => ({})
56
+ axios.defaults.httpAgent = new http.Agent({ keepAlive })
57
+ axios.defaults.httpAgent.toJSON = () => ({})
56
58
 
57
59
  /**
58
60
  * @function sendRequest
@@ -77,11 +79,12 @@ request.defaults.httpAgent.toJSON = () => ({})
77
79
  * @param {SendRequestProtocolVersions | undefined} protocolVersions the config for Protocol versions to be used
78
80
  * @param {'fspiop' | 'iso20022'} apiType the API type of the request being sent
79
81
  * @param {object} axiosRequestOptionsOverride axios request options to override https://axios-http.com/docs/req_config
82
+ * @param {ILogger} [logger] ContextLogger instance with specific context
83
+ * @param {string} [peerService] Logical service name to call (for OTel)
80
84
  * @param {regex} hubNameRegex hubName Regex
81
85
  *
82
86
  *@return {Promise<any>} The response for the request being sent or error object with response included
83
87
  */
84
-
85
88
  const sendRequest = async ({
86
89
  url,
87
90
  headers,
@@ -96,6 +99,8 @@ const sendRequest = async ({
96
99
  protocolVersions = undefined,
97
100
  apiType = API_TYPES.fspiop,
98
101
  axiosRequestOptionsOverride = {},
102
+ logger = createHttpLogger(),
103
+ peerService = '',
99
104
  hubNameRegex
100
105
  }) => {
101
106
  const histTimerEnd = Metrics.getHistogram(
@@ -113,6 +118,9 @@ const sendRequest = async ({
113
118
  // think, if we can just avoid checking "destination"
114
119
  throw ErrorHandler.Factory.createInternalServerFSPIOPError(MISSING_FUNCTION_PARAMETERS)
115
120
  }
121
+
122
+ const log = logger.child({ component: 'httpRequest' })
123
+
116
124
  try {
117
125
  const transformedHeaders = Headers.transformHeaders(headers, {
118
126
  httpMethod: method,
@@ -126,9 +134,10 @@ const sendRequest = async ({
126
134
  url,
127
135
  method,
128
136
  headers: transformedHeaders,
129
- data: payload, // todo: think, if it's better to transform to ISO format here (based on apiType)
137
+ data: payload,
130
138
  params,
131
139
  responseType,
140
+ peerService,
132
141
  timeout: config.get('httpRequestTimeoutMs'),
133
142
  ...axiosRequestOptionsOverride
134
143
  }
@@ -149,14 +158,18 @@ const sendRequest = async ({
149
158
  }
150
159
  span.audit({ ...rest, payload }, EventSdk.AuditEventAction.egress)
151
160
  }
152
- logger.debug('sendRequest::requestOptions:', { requestOptions })
153
- const response = await request(requestOptions)
161
+
162
+ const response = await sendBaseRequest({
163
+ ...requestOptions,
164
+ logger: log,
165
+ peerService
166
+ })
154
167
 
155
168
  !!sendRequestSpan && await sendRequestSpan.finish()
156
169
  histTimerEnd({ success: true, source, destination, method })
157
170
  return response
158
171
  } catch (error) {
159
- logger.error('error in request.sendRequest:', {
172
+ log.error('error in request.sendRequest:', {
160
173
  code: error.code,
161
174
  message: error.message,
162
175
  stack: error.stack,
@@ -247,6 +260,62 @@ const sendRequest = async ({
247
260
  }
248
261
  }
249
262
 
263
+ // todo: think better name
264
+ // it's for http calls without params validation and transformHeaders
265
+ const sendBaseRequest = async ({
266
+ logger = createHttpLogger(),
267
+ peerService = '',
268
+ ...reqOptions
269
+ } = {}) => {
270
+ const log = logger.child({ component: 'sendBaseRequest' })
271
+ const { method, url } = reqOptions
272
+ const methodUrl = `${method?.toUpperCase()} ${url}`
273
+ const startTime = Date.now()
274
+
275
+ let statusCode
276
+ let errorType
277
+
278
+ try {
279
+ log.debug(`[-->] options for ${methodUrl}: `, { reqOptions })
280
+
281
+ const response = await axios(reqOptions)
282
+
283
+ statusCode = response?.status
284
+ log.verbose(`[<--] details of ${methodUrl}: `, {
285
+ data: response?.data,
286
+ headers: response?.headers, // todo: extract only needed headers
287
+ statusCode,
288
+ reqOptions
289
+ })
290
+
291
+ return response
292
+ } catch (error) {
293
+ statusCode = error.response?.status
294
+ errorType = error.code
295
+ throw error // todo: think, if we need to rethrow our custom error here
296
+ } finally {
297
+ const severity = typeof statusCode === 'number'
298
+ ? (statusCode >= 200 && statusCode < 300 ? 'info' : 'warn')
299
+ : 'error'
300
+ const durationSec = (Date.now() - startTime) / 1000
301
+ log[severity](`[<-- ${statusCode || errorType || ''}] ${methodUrl} [${durationSec} s]:`, outgoingRequestAttributesDto({
302
+ method,
303
+ url,
304
+ statusCode,
305
+ durationSec,
306
+ errorType,
307
+ peerService
308
+ }))
309
+ }
310
+ }
311
+
312
+ const createHttpLogger = () => {
313
+ const logger = globalLogger.child()
314
+ logger.setLevel(config.get('httpLogLevel'))
315
+ return logger
316
+ }
317
+
250
318
  module.exports = {
251
- sendRequest
319
+ sendRequest,
320
+ sendBaseRequest
252
321
  }
@@ -134,7 +134,7 @@ Tape('loggingPlugin Tests -->', (pluginTests) => {
134
134
  t.true(statusCode === 500, 'handler failed')
135
135
  t.true(log.info.callCount === 2, 'log request/response')
136
136
  t.true(log.info.lastCall.firstArg.startsWith('[<== 500]'), 'error code is logged')
137
- t.ok(log.info.lastCall.lastArg.output.payload, 'error output is logged')
137
+ t.ok(log.info.lastCall.lastArg.payload, 'error output is logged')
138
138
  }))
139
139
 
140
140
  pluginTests.test('should not log requests on internal routes', tryCatchEndTest(async t => {