UNPKG

@infotorg/winston-log-formats

Version:

Infotorg log formats for Winston logger

395 lines (350 loc) 13.1 kB
const { format } = require('logform'); const { LEVEL, MESSAGE } = require('triple-beam'); const { isObject } = require('./utils/is-object'); const isAxiosError = (info) => { return ( info.isAxiosError === true || // For Axios ^0.25.0 info.name === 'AxiosError' // For Axios ^1.3.3 ); }; const isAxiosRequest = (info) => { if (typeof info.message !== 'object') { return false; } const { message } = info; if (message?.isAxiosRequest) { // First attempt to identify Axios request object. // isAxiosRequest is a custom property that is added to Axios request object in the request interceptor, // and is used to identify Axios request. return true; } // Second attempt to identify Axios request object. const expectedFieldsInConfig = ['method', 'url']; const configFields = new Set(Object.keys(message)); return expectedFieldsInConfig.filter((field) => configFields.has(field)).length === 2; }; const isAxiosResponse = (info) => { if (typeof info.message !== 'object') { return false; } if (info.message?.isAxiosResponse) { // First attempt to identify Axios response object. // isAxiosResponse is a custom property that is added to Axios response object in the response interceptor, // and is used to identify Axios response. return true; } // Second attempt to identify Axios response object. return info.message?.config?.headers['User-Agent']?.includes('axios'); }; /** * Applied to errors like ECONNREFUSED, ECONNRESET, ENOTFOUND, etc. * * @type {string[]} */ const errorFields = ['errno', 'syscall', 'hostname', 'code', 'address', 'port']; const getWhiteListFields = (options) => [ 'url', 'baseURL', 'method', 'data', options?.headers ? 'headers' : null, 'status', 'statusText', // Exists for ECONNREFUSED, ECONNRESET, ENOTFOUND, etc. ...errorFields, // ^^^^^^^^ 'config', 'xsrfCookieName', 'xsrfHeaderName', 'xTrackId', 'timeout', options?.stack ? 'stack' : null, 'responseTime', // Custom field provided by axios interceptors 'requestStartedAt', ].filter(Boolean); /** * Returns an object with all non axios info properties. * They could be provided from other formats like timestamp, request-id, etc. * * @param {Object} info * @returns {Object} */ const topLevelInfo = (info) => { // These keys are exist in Axios Error object // Do not include these keys from top level of Axios Error object const keys = ['config', 'request', 'response', 'isAxiosError', 'toJSON', ...errorFields]; return Object.fromEntries(Object.entries(info).filter(([key]) => keys.includes(key) === false)); }; /** * Extracts meta information from Axios request, response or error object * * @param {Object} data - Axios request, response or error object * @param {string} reqOrResKey - 'req' or 'res' * @param {[]} whiteListFields - array of fields to extract * @returns {{req: {}, res: {} }} */ const extractMeta = (data, reqOrResKey, whiteListFields) => { const result = { [reqOrResKey]: {} }; Object.keys(data) .filter((propName) => whiteListFields.includes(propName)) .forEach((propName) => { if (propName === 'config') { // The only axios Response and Error objects have 'config' key. Request object itself is a config. // In Response and Error objects 'headers' key located under the 'config' key // Remove headers from config also if not specified in the options result.req = extractMeta(data[propName], 'req', whiteListFields).req; } else { result[reqOrResKey][propName] = data[propName]; } }); return result; }; /** * Default axios format options. * * @type {Readonly<{headers: boolean, stack: boolean, errorDescription: string, responseDescription: string, meta: boolean, metaKey: string, requestDescription: string, enabled: boolean}>} */ const defaultOptions = Object.freeze({ enabled: true, // Description for a request requestDescription: 'Axios request', // Description for a response responseDescription: 'Axios response', // Description for an error errorDescription: 'Axios error', // If true, then request/response/error information will be included in meta meta: false, // Key name for meta property metaKey: 'meta', // If true, then headers will be included in the meta headers: true, // If true, then error stack trace will be included in the meta stack: false, }); /** * function axios (info) * Returns a new instance of the axios Format which transforms `message` property if this is an axios request/response/error * and adds this information into `metaKey` property in the message. * * If the `message` property of the `info` object is an instance of `AxiosError`, * replace the `Error` object its own `message` property and meta information. * * @param {Object|Error} [axiosInfo={}] The info parameter provided to a given format represents a single log message. The object itself is mutable. Every info must have at least the level and message properties. * @param {string|{}} axiosInfo.message Error message or request/response object. Required * @param {string} axiosInfo.level Log level. Required * @param {string|undefined} [axiosInfo.label=undefined] Log label. Optional * * @param {Object} [opts={meta: false, metaKey: 'meta', requestDescription: 'Axios request', responseDescription: 'Axios response', errorDescription: 'Axios error', stack: false, headers: true}] Setting specific options to the current instance of the format. Example: { headers: true, meta: true, metaKey: 'meta', requestDescription: 'Axios request', responseDescription: 'Axios response', errorDescription: 'Axios error', stack: true } * * @param {string} [opts.enabled=true] Toggle axios format output. * @param {string} [opts.requestDescription='Axios request'] Description for a Request. * @param {string} [opts.responseDescription='Axios response'] Description for a Response. * @param {string} [opts.errorDescription='Axios error'] Description for an Error. * @param {boolean} [opts.meta=false] If true, then request/response/error information will be included in meta. * @param {string} [opts.metaKey='meta'] Key name for meta property. * @param {boolean} [opts.stack=false] If true, then error stack trace will be included in the meta. * * @returns {Function} */ module.exports = format((axiosInfo = {}, opts = { ...defaultOptions }) => { if (!axiosInfo || isObject(axiosInfo) === false || Object.keys(axiosInfo).length === 0) { // This is not a log object return {}; } const options = isObject(opts) && Object.keys(opts).length ? { ...defaultOptions, ...opts } : { ...defaultOptions }; const { enabled, metaKey } = options; if (!enabled) { // Format is disabled, so we return the info object return axiosInfo; } if (!isAxiosError(axiosInfo) && !isAxiosResponse(axiosInfo) && !isAxiosRequest(axiosInfo)) { // Not an axios Error/Request/Response return axiosInfo; } // Initialize result log object const info = { ...topLevelInfo(axiosInfo), level: axiosInfo.level, [LEVEL]: axiosInfo[LEVEL] || axiosInfo.level, message: axiosInfo.message, [MESSAGE]: axiosInfo[MESSAGE] || axiosInfo.message, [metaKey]: {}, }; // Object with meta information for request or response let meta; // req or res key in 'meta' object let metaResultProp = 'res'; if (isAxiosError(axiosInfo)) { // Axios error info.description = axiosInfo.description || options.errorDescription; if (axiosInfo.request && !axiosInfo.response) { // Request was made but no response was received // `axiosInfo.request` is an instance of XMLHttpRequest in the browser and an instance of http.ClientRequest in node.js // Used for errors like ECONNREFUSED, ECONNRESET, ENOTFOUND, etc. // Technically there is no response here, but to make it more understandable in logs what was requested, // and what was a "response" we make it compatible with response format. // Example of what will be logged for the 'ENOTFOUND' error: // { // "description": "Axios Error", // "label": "development", // "level": "error", // "message": "getaddrinfo ENOTFOUND media.xy", // "meta": { // "req": { // "headers": { // "Accept": "application/json, text/plain, */*", // "User-Agent": "axios/1.3.3" // }, // "method": "get", // "requestStartedAt": 1673964871763, // "timeout": "0", // "url": "http://media.xy/eee", // }, // "res": { // "code": "ENOTFOUND", // "status": null // } // }, // "timestamp": "2023-02-07 17:53:36.730" // } const { config, message, stack, code, status } = axiosInfo.toJSON(); const { errno, syscall, hostname, address, port } = axiosInfo; info.message = message; if (options.meta) { meta = { errno, code, syscall, hostname, address, port, status, config, stack, }; } } if (axiosInfo.response) { // Axios Error when server response with some status code const { config: { url, method, baseURL }, status, statusText, responseTime, } = axiosInfo.response; info.message = [ String(method).toUpperCase(), baseURL ? new URL(url, baseURL).href : url, status, statusText, responseTime ? `${responseTime}ms` : null, ] .filter(Boolean) .join(' '); // Example of what will be logged for the '404 Not Found' error: // { // "description": "Axios Error", // "label": "development", // "level": "error", // "message": "GET http://localhost:1337/uploads/search_1bff605b0f1.svg 404 Not Found 18ms", // "meta": { // "req": { // "headers": { // "Accept": "application/json, text/plain, */*", // "User-Agent": "axios/1.3.3" // }, // "method": "get", // "requestStartedAt": 1673964813765, // "timeout": "0", // "url": "http://localhost:1337/uploads/search_1bff605b0f1.svg", // }, // "res": { // "data": { // "data": null, // "error": { // "details": {}, // "message": "Not Found", // "name": "NotFoundError", // "status": 404 // } // }, // "headers": { // "accept-ranges": "bytes", // "content-length": "94", // "content-type": "application/json; charset=utf-8", // }, // "status": 404, // "statusText": "Not Found" // } // }, // "timestamp": "2023-02-07 17:53:36.730" // } if (options.meta) { meta = { ...axiosInfo.response, }; } } } if (isAxiosRequest(axiosInfo)) { metaResultProp = 'req'; info.description = axiosInfo.description || options.requestDescription; const request = axiosInfo.message; const { url, baseURL, method, // Other available keys in req: // data, // headers, } = request; info.message = [method.toUpperCase(), baseURL ? new URL(url, baseURL).href : url].filter(Boolean).join(' '); if (options.meta) { meta = { ...request, }; } } if (isAxiosResponse(axiosInfo)) { // Axios request or response info.description = axiosInfo.description || options.responseDescription; const { config: { url, method, baseURL }, status, statusText, responseTime, // Other available keys in res: // data, // headers, } = axiosInfo.message; // Create a basic message with HTTP method, url, response code and response time: // POST https://spin-tm-proxy.infotorg-eastnor-test.dds.evry.cloud/ 200 OK 189ms info.message = [ String(method).toUpperCase(), baseURL ? new URL(url, baseURL).href : url, status, statusText, responseTime ? `${responseTime}ms` : null, // add response time if it exists ] .filter(Boolean) .join(' '); if (options.meta) { meta = { ...axiosInfo.message, }; } } if (!options.meta) { // No need to add meta information to the log message return info; } try { info[metaKey] = extractMeta(meta, metaResultProp, getWhiteListFields(options)); } catch (e) { // eslint-disable-next-line no-console console.error(e); } return info; });