feathersjs/feathers-profiler

View on GitHub
src/index.js

Summary

Maintainability
B
4 hrs
Test Coverage

const debug = require('debug')('feathers-profiler');

let app;
let options;
let pending = 0;
let cache = {};

const cacheEntry = {
  // sum all calls
  calledCount: 0,       // #times service called
  pendingTotal: 0,      // total #pending service calls at start of these calls
  pendingAvg: 0,        // average #pending service calls at start of a call
  // sum successful calls
  resolvedCount: 0,     // #times service call completed successfully
  nanoTotal: 0,         // total nano-secs (sec/1e9) spent on successful calls
  avgMs: 0,             // average milli-sec per successful call
  nanoMin: Infinity,    // shortest successful call
  nanoMax: -1,          // longest successful call
  resultItemsCount: 0   // #items read in find and get calls
};

export function profiler (options1 = {}) {
  options = Object.assign({}, {
    logger: { log: msg => console.log(msg) },
    logMsg: defaultLogMsg,
    stats: 'total',
    statsDetail: hook => `id:${typeof hook.id}, params:${JSON.stringify(hook.params)}`
  },
    options1);

  options.stats = options.stats || 'none';

  if (!['detail', 'total', 'none'].includes(options.stats)) {
    throw new Error('stats option invalid. (profiler)');
  }
  if (options.logger && typeof options.logger.log !== 'function') {
    throw new Error('Logger.log is not a function. (feathers-profiler)');
  }
  if (typeof options.logMsg !== 'function') {
    throw new Error('logMsg is not a function. (feathers-profiler)');
  }
  if (typeof options.statsDetail !== 'function') {
    throw new Error('statsDetail is not a function. (feathers-profiler)');
  }

  return function () {
    debug('Initializing logger-stats plugin');
    app = this;

    if (typeof app.hooks !== 'function') {
      throw new Error('feathers-hooks >= 1.6.0 is needed. (logger-stats)');
    }

    instrumentServices();
  };
}

export function defaultLogMsg (hook) {
  hook._log = hook._log || {};
  const elapsed = Math.round(hook._log.elapsed / 1e5) / 10;
  const header = `${timestamp()} ${hook._log.route}::${hook.method}`;
  const trailer = `(${hook.params.provider || 'server'}) ${elapsed} ms - ${pending} pending`;

  return `${header} ${trailer}` +
    (hook.error ? ` - FAILED ${(hook.original || {}).type} ${hook.error.message || ''}` : '');
}

function instrumentServices () {
  debug('Creating app.hooks');
  app.hooks({
    before: { all: timeStart },
    after: { all: timeEnd },
    error: {
      all: (hook) => {
        hook._log = hook._log || { route: '', key: '', hrtime: [0, 0], elapsed: 0 };

        if (hook._log.hrtime !== 0) {
          const diff = process.hrtime(hook._log.hrtime || [0, 0]);
          hook._log.elapsed = (diff[0] * 1e9) + diff[1];
        }

        pending += -1;

        if (options.logger) {
          options.logger.log(options.logMsg(hook));
        }
      }
    }
  });
}

export function timeStart (hook) {
  const route = hook.path; // feathers-hooks v1.7.0 required
  let key = '';

  debug(`timeStart ${route} ${hook.method} ${hook.params.provider}`);

  if (options.stats !== 'none') {
    cache[route] = cache[route] || {};
    cache[route][hook.method] = cache[route][hook.method] || {};

    key = getKey(options);

    const routeMethod = cache[route][hook.method];
    routeMethod[key] = routeMethod[key] || Object.assign({}, cacheEntry);
    routeMethod[key].calledCount += 1;
    routeMethod[key].pendingTotal += pending;
  }

  pending += 1;

  hook._log = {
    route,
    key,
    hrtime: [0, 0],
    elapsed: 0
  };
  hook._log.hrtime = process.hrtime(); // V8 bug: breaks if inside the above object literal

  return hook;

  function getKey (options) { // to reduce complexity for code climate
    return options.stats === 'detail' ? (options.statsDetail(hook) || '_misc') : '_total';
  }
}

export function timeEnd (hook) {
  if (!hook._log || !hook._log.hrtime) {
    return; // ignore RethinkDB change-feed
  }

  const diff = process.hrtime(hook._log.hrtime);
  pending += -1;
  debug(`timeEnd ${hook._log.route} ${hook.method} ${hook.params.provider}`);

  hook._log.elapsed = (diff[0] * 1e9) + diff[1];

  if (options.stats !== 'none') {
    const entry = cache[hook._log.route][hook.method][hook._log.key];
    const nano = hook._log.elapsed;

    entry.resolvedCount += 1;
    entry.nanoTotal += nano;
    entry.nanoMin = Math.min(entry.nanoMin, nano);
    entry.nanoMax = Math.max(entry.nanoMax, nano);

    if (hook.method === 'find' || hook.method === 'get') {
      const items = getItems(hook);
      entry.resultItemsCount += Array.isArray(items) ? items.length : 1;
    }
  }

  if (options.logger) {
    options.logger.log(options.logMsg(hook));
  }

  function getItems (hook) { // to reduce complexity for code climate
    const result = hook.result;
    return result ? result.data || result : result;
  }
}

export function getProfile () {
  debug('Get timings');

  if (options.stats !== 'none') {
    Object.keys(cache).forEach(route => {
      Object.keys(cache[route]).forEach(method => {
        if (options.stats === 'detail') {
          const total = Object.assign({}, cacheEntry);
          const rM = cache[route][method];

          Object.keys(rM).forEach(key => {
            const rMK = rM[key];
            rMK.avgMs = !rMK.resolvedCount ? 0 : rMK.nanoTotal / rMK.resolvedCount / 1e6;
            rMK.pendingAvg = !rMK.resolvedCount ? 0 : rMK.pendingTotal / rMK.resolvedCount;

            total.calledCount += rMK.calledCount;
            total.resolvedCount += rMK.resolvedCount;
            total.nanoTotal += rMK.nanoTotal;
            total.nanoMin = Math.min(total.nanoMin, rMK.nanoMin);
            total.nanoMax = Math.max(total.nanoMax, rMK.nanoMax);
            total.resultItemsCount += rMK.resultItemsCount;
            total.pendingTotal += rMK.pendingTotal;
          });

          total.avgMs = !total.resolvedCount ? 0 : total.nanoTotal / total.resolvedCount / 1e6;
          total.pendingAvg = !total.calledCount ? 0 : total.pendingTotal / total.calledCount;

          cache[route][method]._total = total;
        } else {
          const total2 = cache[route][method]._total;

          total2.avgMs = !total2.resolvedCount ? 0 : total2.nanoTotal / total2.resolvedCount / 1e6;
          total2.pendingAvg = !total2.calledCount ? 0 : total2.pendingTotal / total2.calledCount;

          cache[route][method]._total = total2;
        }
      });
    });
  }

  return cache;
}

export function getPending () {
  debug('getPending', pending);
  return pending;
}

export function clearProfile () {
  debug('Clearing cache');
  cache = {};
}

export function timestamp () {
  const date = new Date();
  const last2 = (numb) => `0${numb}`.slice(-2);
  return `${last2(date.getHours())}:${last2(date.getMinutes())}:${last2(date.getSeconds())}`;
}