grantcarthew/node-perj

View on GitHub
src/perj.js

Summary

Maintainability
B
5 hrs
Test Coverage
import { options as defaultOptions } from "./options.js";
import { notationCopy } from "./notation-copy.js";

// Symbols for functions and values
const _HeaderStrings = Symbol("HeaderStrings");
const _HeaderValues = Symbol("HeaderValues");
const _Options = Symbol("Options");
const _Parent = Symbol("Parent");
const _SetLevelFunction = Symbol("SetLevelFunction");
const _SetLevelHeader = Symbol("SetLevelHeader");
const _SplitOptions = Symbol("SplitOptions");
const _TopIsPrimitive = Symbol("TopIsPrimitive");
const _TopSnip = Symbol("TopSnip");
const _TopValues = Symbol("TopValues");

/*
Code Summary:
Following are points of interest around the perj code choices.
- Symbols used to hide internal properties and methods.
- Multiple Symbols due to major performance hit if using nested.
- Deeply nested 'if' and 'for' statements due to performance benifits.
- Some minor duplication due to performance benifits.

Symbol Summary:
_SplitOptions: <Function>
  Used to split the user options from top level properties.
  Helps to abstract the split function out of the constructor.

_Options: <Object>
  This holds an Object with the default or custom options.

_TopSnip: <String>
  This holds a JSON snippet of the user supplied top level properties.

_TopValues: <Object>
  This holds the user supplied top level properies.
  It is used to build the _TopSnip string and when
  'passThrough' is enabled.

_TopIsPrimitive: <Boolean>
  Object copy is a real pig to work with in a high
  performance project. To avoid copying objects, this flag
  is used to indicate if the user has assigned a top level property
  with values that are only simple primitives (string, number, boolean).
  If the user assigned values are all simple primitives, a 'for' loop
  is faster to duplicate an object.
  This flag is also set to false if the user supplies a custom
  stringify function.

_HeaderStrings: <Object>
  The header for each log level is the same such as:
  {"level":"error","lvl":50,"time":1525643291716
  This caches the level headers.

_HeaderValues: <Object>
  Only used when 'passThrough' is enabled.
  Permits rebuilding the JSON object for output.

_SetLevelHeader: <Function>
  Holds the function used to build the level header.

_SetLevelFunction: <Function>
  This function is used to generate the level functions.
*/
class Perj {
  constructor(options) {
    if (options != null && options.constructor !== Object) {
      throw new Error("Provide options object to create a logger.");
    }
    this[_Parent] = null;
    this[_Options] = Object.assign({}, defaultOptions);
    this[_TopSnip] = "";
    this[_TopValues] = {};
    this[_TopIsPrimitive] = true;
    this[_SplitOptions](options);
    this[_HeaderStrings] = {};
    this[_HeaderValues] = {};
    for (const level in this[_Options].levels) {
      this[_SetLevelHeader](level);
      this[_SetLevelFunction](level);
    }
  }

  get level() {
    return this[_Options].level;
  }

  set level(level) {
    if (!Object.hasOwn(this[_Options].levels, level)) {
      throw new Error("The level option must be a valid key in the levels object.");
    }
    if (!Object.hasOwn(this, _Options)) {
      // Attaching the options object to this instance
      this[_Options] = Object.assign({}, this[_Options]);
    }
    this[_Options].level = level;
  }

  get levels() {
    return this[_Options].levels;
  }

  addLevel(newLevels) {
    for (const level in newLevels) {
      if (this[level]) {
        continue;
      }
      this[_Options].levels[level] = newLevels[level];
      this[_SetLevelHeader](level);
      this[_SetLevelFunction](level);
    }
  }

  get parent() {
    return this[_Parent];
  }

  get write() {
    return this[_Options].write;
  }

  [_SplitOptions](options) {
    if (!options) {
      return;
    }
    for (const key in options) {
      if (Object.hasOwn(defaultOptions, key)) {
        if (key === "level") {
          this.level = options[key];
          continue;
        }
        if (key === "stringifyFunction") {
          this[_TopIsPrimitive] = false;
        }
        this[_Options][key] = options[key];
      } else {
        const type = typeof options[key];
        if (type === "string") {
          this[_TopSnip] += '"' + key + '":"' + options[key] + '",';
          this[_TopValues][key] = options[key];
        } else if (type === "number" || type === "boolean") {
          this[_TopSnip] += '"' + key + '":' + options[key] + ",";
          this[_TopValues][key] = options[key];
        } else if (type === "undefined") {
          this[_TopSnip] += '"' + key + '":null,';
          this[_TopValues][key] = null;
        } else {
          this[_TopSnip] += '"' + key + '":' + this[_Options].stringifyFunction(options[key]) + ",";
          this[_TopValues][key] = options[key];
          this[_TopIsPrimitive] = false;
        }
      }
    }
  }

  [_SetLevelHeader](level) {
    this[_HeaderStrings][level] = "{";
    this[_Options].levelKeyEnabled &&
      (this[_HeaderStrings][level] += '"' + this[_Options].levelKey + '":"' + level + '",');
    this[_Options].levelNumberKeyEnabled &&
      (this[_HeaderStrings][level] += '"' + this[_Options].levelNumberKey + '":' + this[_Options].levels[level] + ",");
    this[_TopSnip] !== "" && (this[_HeaderStrings][level] += this[_TopSnip]);
    this[_HeaderStrings][level] += '"' + this[_Options].dateTimeKey + '":';

    if (this[_Options].passThrough) {
      const levelObj = {};
      this[_Options].levelKeyEnabled && (levelObj[this[_Options].levelKey] = level);
      this[_Options].levelNumberKeyEnabled && (levelObj[this[_Options].levelNumberKey] = this[_Options].levels[level]);
      this[_HeaderValues][level] = notationCopy(levelObj, this[_TopValues]);
    }
  }

  [_SetLevelFunction](level) {
    this[level] = function (...items) {
      if (this[_Options].levels[this[_Options].level] > this[_Options].levels[level]) {
        return;
      }
      const time = this[_Options].dateTimeFunction();
      let msg = "";
      let data = null;
      let dataJson = "null";
      let isError = false;

      const serialize = (item) => {
        if (!this[_Options].serializers) {
          return item;
        }
        if (item == null) {
          return null;
        }
        const graded = {};
        for (const key in item) {
          let value = item[key];
          if (Object.hasOwn && Object.hasOwn(item, key) && this[_Options].serializers[key]) {
            value = this[_Options].serializers[key](value);
          }
          if (value !== undefined) {
            graded[key] = value;
          }
        }
        return graded;
      };

      if (items.length === 1) {
        // Single item processing
        const item = items[0];
        const type = typeof item;
        if (type === "string") {
          msg = item;
        } else if (item == null || type === "function") {
          // Undefined or null, keep defaults.
        } else if (type === "number" || type === "boolean") {
          data = item;
          dataJson = "" + item;
        } else if (item instanceof Error) {
          isError = true;
          msg = item.message;
          data = this[_Options].serializeErrorFunction(item);
          dataJson = this[_Options].stringifyFunction(data);
        } else {
          data = serialize(item);
          dataJson = this[_Options].stringifyFunction(data);
        }
      } else if (items.length > 1) {
        // Multiple item processing
        data = [];
        for (const item of items) {
          const type = typeof item;
          if (type === "string") {
            if (msg) {
              data.push(item);
            } else {
              msg = item;
            }
            continue;
          }
          if (item == null || type === "function") {
            data.push(null);
            continue;
          }
          if (item instanceof Error) {
            isError = true;
            data.push(this[_Options].serializeErrorFunction(item));
            if (!msg) {
              msg = item.message;
            }
            continue;
          }

          data.push(serialize(item));
        }

        if (data.length === 1) {
          data = data[0];
        }
        dataJson = this[_Options].stringifyFunction(data);
      }

      let json =
        this[_HeaderStrings][level] +
        time +
        ',"' +
        this[_Options].messageKey +
        '":"' +
        msg +
        '","' +
        this[_Options].dataKey +
        '":' +
        dataJson;
      if (isError) {
        json += ',"error":true';
      }
      json += "}\n";

      if (this[_Options].passThrough) {
        const obj = notationCopy({}, this[_HeaderValues][level], {
          [this[_Options].dateTimeKey]: time,
          [this[_Options].messageKey]: msg,
          [this[_Options].dataKey]: data,
        });
        if (isError) {
          obj.error = true;
        }
        this[_Options].write(json, obj);
      } else {
        this[_Options].write(json);
      }
    };
  }

  child(tops) {
    if (tops == null || tops.constructor !== Object) {
      throw new Error("Provide top level arguments object to create a child logger.");
    }
    const newChild = Object.create(this);
    if (this[_TopIsPrimitive]) {
      // Avoiding Object.assign which is not needed
      newChild[_TopValues] = {};
      newChild[_TopIsPrimitive] = true;
      for (const key in this[_TopValues]) {
        newChild[_TopValues][key] = this[_TopValues][key];
      }
    } else {
      // Top value is an object. Take the object copy hit like a man.
      newChild[_TopValues] = notationCopy({}, this[_TopValues]);
      newChild[_TopIsPrimitive] = false;
    }
    for (const key in tops) {
      // Options and key names are not valid, skipping.
      if (
        Object.hasOwn(defaultOptions, key) ||
        this[_Options].levelKey === key ||
        this[_Options].levelNumberKey === key ||
        this[_Options].dateTimeKey === key ||
        this[_Options].messageKey === key ||
        this[_Options].dataKey === key
      ) {
        continue;
      }
      const type = typeof tops[key];
      if (type === "string" && Object.hasOwn(this[_TopValues], key) && typeof this[_TopValues][key] === "string") {
        // New top key is the same as parent and is a string. Appending separator string and new value.
        newChild[_TopValues][key] = this[_TopValues][key] + this[_Options].separatorString + tops[key];
      } else if (type === "undefined") {
        newChild[_TopValues][key] = null;
      } else {
        newChild[_TopValues][key] = tops[key];
        // Not using && so we can exit early
        if (!(type === "string" || type === "number" || type === "boolean")) {
          this[_TopIsPrimitive] = false;
        }
      }
    }
    newChild[_TopSnip] = "";
    for (const key in newChild[_TopValues]) {
      if (newChild[_TopIsPrimitive]) {
        // Privitive JSON.stringify. Cheap.
        const type = typeof newChild[_TopValues][key];
        if (type === "string") {
          newChild[_TopSnip] += '"' + key + '":"' + newChild[_TopValues][key] + '",';
        } else {
          newChild[_TopSnip] += '"' + key + '":' + newChild[_TopValues][key] + ",";
        }
        continue;
      }
      newChild[_TopSnip] += '"' + key + '":' + this[_Options].stringifyFunction(newChild[_TopValues][key]) + ",";
    }
    newChild[_Parent] = this;
    newChild[_HeaderStrings] = {};
    newChild[_HeaderValues] = {};
    for (const level in this[_Options].levels) {
      newChild[_SetLevelHeader](level);
    }
    return newChild;
  }

  stringify(obj, replacer, spacer) {
    return this[_Options].stringifyFunction(obj, replacer, spacer);
  }

  json(data) {
    console.log(this[_Options].stringifyFunction(data, null, 2));
  }
}

export default Perj;