@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
JavaScript
;
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,
};