UNPKG

@cap-js-community/feature-toggle-library

Version:

SAP BTP feature toggle library enables Node.js applications using the SAP Cloud Application Programming Model to maintain live-updatable feature toggles via Redis.

277 lines (249 loc) 8.71 kB
"use strict"; const util = require("util"); const VError = require("verror"); const { CfEnv } = require("./cf-env"); const { tryRequire } = require("./static"); const cds = tryRequire("@sap/cds"); const cdsPackage = tryRequire("@sap/cds/package.json"); // NOTE: for sap/cds < 7.7.0 it was expected get the subdomain from express request const doReqSubdomain = cdsPackage?.version.localeCompare("7.7.0", undefined, { numeric: true, sensitivity: "base" }) < 0; // NOTE: for 7.7.0 <= sap/cds < 9.3.0 it was expected to get the subdomain from cds request .user.tokenInfo const doTokenInfoSubdomain = !doReqSubdomain && cdsPackage?.version.localeCompare("9.3.0", undefined, { numeric: true, sensitivity: "base" }) < 0; const ENV = Object.freeze({ LOG_LEVEL: "BTP_FEATURES_LOG_LEVEL", }); // NOTE: logger levels are tricky. looking at console, npm, winston, and cap there is no real consistency. we will // offer the same levels as console and an additional "off" level. const LEVEL = Object.freeze({ OFF: "OFF", // SILENT: "SILENT" ERROR: "ERROR", WARNING: "WARNING", INFO: "INFO", DEBUG: "DEBUG", // VERBOSE: "VERBOSE", TRACE: "TRACE", // SILLY: "SILLY" }); const LEVEL_NUMBER = Object.freeze({ [LEVEL.OFF]: 0, [LEVEL.ERROR]: 100, [LEVEL.WARNING]: 200, [LEVEL.INFO]: 300, [LEVEL.DEBUG]: 400, [LEVEL.TRACE]: 500, }); const LEVEL_NAME = Object.freeze({ [LEVEL.ERROR]: "error", [LEVEL.WARNING]: "warn", // NOTE: cf-nodejs-logging-support started using warn instead of warning, and now we cannot change it [LEVEL.INFO]: "info", [LEVEL.DEBUG]: "debug", [LEVEL.TRACE]: "trace", }); const FIELD = Object.freeze({ // CF ENV DATA COMPONENT_NAME: "component_name", COMPONENT_ID: "component_id", COMPONENT_INSTANCE: "component_instance", COMPONENT_TYPE: "component_type", SPACE_NAME: "space_name", SPACE_ID: "space_id", ORGANIZATION_NAME: "organization_name", ORGANIZATION_ID: "organization_id", CONTAINER_ID: "container_id", // BASE DATA TYPE: "type", LAYER: "layer", // ASYNC_LOCAL_STORAGE CDS CONTEXT DATA CORRELATION_ID: "correlation_id", REMOTE_USER: "remote_user", TENANT_ID: "tenant_id", TENANT_SUBDOMAIN: "tenant_subdomain", // LOG INVOCATION DATA LEVEL: "level", WRITTEN_AT: "written_at", WRITTEN_TIME: "written_ts", MESSAGE: "msg", }); const FORMAT = Object.freeze({ JSON: "JSON", TEXT: "TEXT", }); const MILLIS_IN_NANOS_NUMBER = 1000000; const MILLIS_IN_NANOS_BIGINT = BigInt(MILLIS_IN_NANOS_NUMBER); const cfEnv = CfEnv.getInstance(); const cfApp = cfEnv.cfApp; const cfAppData = cfEnv.isOnCf ? { [FIELD.COMPONENT_TYPE]: "application", [FIELD.COMPONENT_NAME]: cfApp.application_name, [FIELD.COMPONENT_ID]: cfApp.application_id, [FIELD.COMPONENT_INSTANCE]: cfEnv.cfInstanceIndex, [FIELD.SPACE_NAME]: cfApp.space_name, [FIELD.SPACE_ID]: cfApp.space_id, [FIELD.ORGANIZATION_NAME]: cfApp.organization_name, [FIELD.ORGANIZATION_ID]: cfApp.organization_id, [FIELD.CONTAINER_ID]: cfEnv.cfInstanceIp, } : undefined; /** * Logger is an implementation of a logger that simultaneously produces natural human-readable logs locally and, in * Cloud Foundry environments, will produce logs in the form of JSON objects with special properties. */ class Logger { static getEnvMaxLevelNumber() { if (Logger.__envMaxLevelNumber === undefined) { Logger.__envMaxLevelNumber = null; let envLogLevel = process.env[ENV.LOG_LEVEL]?.trim().toUpperCase(); if (envLogLevel) { const level = Object.values(LEVEL).find((level) => level.startsWith(envLogLevel)); if (level) { Logger.__envMaxLevelNumber = LEVEL_NUMBER[level]; } } } return Logger.__envMaxLevelNumber; } static _reset() { Reflect.deleteProperty(Logger, "__envMaxLevelNumber"); } constructor( layer = undefined, { type = "log", maxLevel = LEVEL.INFO, customData, format = cfEnv.isOnCf ? FORMAT.JSON : FORMAT.TEXT, inspectOptions = { colors: false }, } = {} ) { this.__baseData = { [FIELD.TYPE]: type, [FIELD.LAYER]: layer, }; this.__dataList = customData ? [customData] : []; this.__format = format; this.__inspectOptions = inspectOptions; this.__maxLevelNumber = LEVEL_NUMBER[maxLevel]; } child(data) { const child = new Logger(); Object.assign(child, this); // NOTE: object.assign only does a shallow copy, so changes to __dataList would propagate to the children. to avoid // this it needs to be cloned here. child.__dataList = child.__dataList.slice(); child.__dataList.push(data); return child; } _logData(level, args) { let message; if (args.length > 0) { const firstArg = args[0]; // special handling if the only arg is a VError if (firstArg instanceof VError) { const err = firstArg; const errInfo = VError.info(err); if (errInfo && Object.keys(errInfo).length > 0) { message = util.formatWithOptions(this.__inspectOptions, "%s\n%O", VError.fullStack(err), errInfo); } else { message = util.formatWithOptions(this.__inspectOptions, "%s", VError.fullStack(err)); } } // special handling if the only arg is an Error else if (firstArg instanceof Error) { const err = firstArg; message = util.formatWithOptions(this.__inspectOptions, "%s", err.stack); } // normal handling else { message = util.formatWithOptions(this.__inspectOptions, ...args); } } const cdsContext = cds?.context; let cdsData; if (cdsContext) { cdsData = { [FIELD.CORRELATION_ID]: cdsContext.id, [FIELD.REMOTE_USER]: cdsContext.user?.id, [FIELD.TENANT_ID]: cdsContext.tenant, }; if (doReqSubdomain) { cdsData[FIELD.TENANT_SUBDOMAIN] = cdsContext?.http?.req?.authInfo?.getSubdomain?.(); } else if (doTokenInfoSubdomain) { cdsData[FIELD.TENANT_SUBDOMAIN] = cdsContext.user?.tokenInfo?.getSubdomain?.(); } else { cdsData[FIELD.TENANT_SUBDOMAIN] = cdsContext.user?.authInfo?.getSubdomain?.(); } } // NOTE: the start time of Date's milliseconds is the epoch and the start time for hrtime is an arbitrary time // close to the process startup, so it may look odd to add them here. however, we can use the sub-millisecond // offset of hrtime to keep logs with the same Date-millisecond in chronological order. const now = new Date(); const nowNanos = now.getTime() * MILLIS_IN_NANOS_NUMBER + Number(process.hrtime.bigint() % MILLIS_IN_NANOS_BIGINT); const invocationData = { [FIELD.LEVEL]: LEVEL_NAME[level], [FIELD.WRITTEN_AT]: now.toISOString(), [FIELD.WRITTEN_TIME]: nowNanos, [FIELD.MESSAGE]: message ?? "", }; return Object.assign({}, cfAppData, ...this.__dataList, invocationData, this.__baseData, cdsData); } static _readableOutput(data) { const writtenTime = new Date(Math.floor(data[FIELD.WRITTEN_TIME] / MILLIS_IN_NANOS_NUMBER)); const timestamp = util.format( "%s:%s:%s.%s", ("0" + writtenTime.getHours()).slice(-2), ("0" + writtenTime.getMinutes()).slice(-2), ("0" + writtenTime.getSeconds()).slice(-2), ("00" + writtenTime.getMilliseconds()).slice(-3) ); const level = data[FIELD.LEVEL].toUpperCase(); const layer = data[FIELD.LAYER]; const message = data[FIELD.MESSAGE]; const parts = [timestamp, level, ...(layer ? [layer] : []), message]; return parts.join(" | "); } _log(level, args) { const levelNumber = LEVEL_NUMBER[level]; if ( (Logger.getEnvMaxLevelNumber() !== undefined && Logger.getEnvMaxLevelNumber() !== null && Logger.getEnvMaxLevelNumber() < levelNumber) || this.__maxLevelNumber < levelNumber ) { return; } const streamOut = level === LEVEL.ERROR ? process.stderr : process.stdout; const data = this._logData(level, args); switch (this.__format) { case FORMAT.JSON: { streamOut.write(JSON.stringify(data) + "\n"); break; } case FORMAT.TEXT: { streamOut.write(Logger._readableOutput(data) + "\n"); break; } } } error(...args) { return this._log(LEVEL.ERROR, args); } warning(...args) { return this._log(LEVEL.WARNING, args); } info(...args) { return this._log(LEVEL.INFO, args); } debug(...args) { return this._log(LEVEL.DEBUG, args); } trace(...args) { return this._log(LEVEL.TRACE, args); } } module.exports = { ENV, LEVEL, FORMAT, Logger, };