lib/models/instrumentation.js

'use strict';

const fs = require('fs-extra');
const chalk = require('chalk');
const heimdallGraph = require('heimdalljs-graph');
const getConfig = require('../utilities/get-config');
const utilsInstrumentation = require('../utilities/instrumentation');
const logger = require('heimdalljs-logger')('ember-cli:instrumentation');
const hwinfo = require('./hardware-info');

let vizEnabled = utilsInstrumentation.vizEnabled;
let instrumentationEnabled = utilsInstrumentation.instrumentationEnabled;

function _enableFSMonitorIfInstrumentationEnabled(config) {
  let monitor;
  if (instrumentationEnabled(config)) {
    const FSMonitor = require('heimdalljs-fs-monitor');
    monitor = new FSMonitor();
    monitor.start();
  }
  return monitor;
}

_enableFSMonitorIfInstrumentationEnabled();

function _getHardwareInfo(platform) {
  const startTime = process.hrtime();

  Object.getOwnPropertyNames(hwinfo).forEach((metric) => (platform[metric] = hwinfo[metric]()));

  const collectionTime = process.hrtime(startTime);

  // Convert from integer [seconds, nanoseconds] to floating-point milliseconds.
  platform.collectionTime = collectionTime[0] * 10e3 + collectionTime[1] / 10e6;
}

class Instrumentation {
  /**
     An instance of this class is used for invoking the instrumentation
     hooks on addons.

     The instrumentation types currently supported are:

     * init
     * build
     * command
     * shutdown

     @class Instrumentation
     @private
  */
  constructor(options) {
    this.isVizEnabled = vizEnabled;
    this.isEnabled = instrumentationEnabled;

    this.ui = options.ui;

    // project constructor will set up bidirectional link
    this.project = null;

    this.instrumentations = {
      init: options.initInstrumentation,
      build: {
        token: null,
        node: null,
        count: 0,
      },
      command: {
        token: null,
        node: null,
      },
      shutdown: {
        token: null,
        node: null,
      },
    };

    this._heimdall = null;

    this.config = getConfig();

    if (!options.initInstrumentation && this.isEnabled()) {
      this.instrumentations.init = {
        token: null,
        node: null,
      };
      this.start('init');
    }
  }

  _buildSummary(tree, result, resultAnnotation) {
    let buildSteps = 0;
    let totalTime = 0;

    let node;
    let statName;
    let statValue;
    let nodeItr;
    let statsItr;
    let nextNode;
    let nextStat;

    for (nodeItr = tree.dfsIterator(); ; ) {
      nextNode = nodeItr.next();
      if (nextNode.done) {
        break;
      }

      node = nextNode.value;
      if (node.label.broccoliNode && !node.label.broccoliCachedNode) {
        ++buildSteps;
      }

      for (statsItr = node.statsIterator(); ; ) {
        nextStat = statsItr.next();
        if (nextStat.done) {
          break;
        }

        statName = nextStat.value[0];
        statValue = nextStat.value[1];

        if (statName === 'time.self') {
          totalTime += statValue;
        }
      }
    }

    let summary = {
      build: {
        type: resultAnnotation.type,
        count: this.instrumentations.build.count,
        outputChangedFiles: null,
      },
      platform: {
        name: process.platform,
      },
      output: null,
      totalTime,
      buildSteps,
    };

    _getHardwareInfo(summary.platform);

    if (result) {
      summary.build.outputChangedFiles = result.outputChanges;
      summary.output = result.directory;
    }

    if (resultAnnotation.type === 'rebuild') {
      summary.build.primaryFile = resultAnnotation.primaryFile;
      summary.build.changedFileCount = resultAnnotation.changedFiles.length;
      summary.build.changedFiles = resultAnnotation.changedFiles.slice(0, 10);
    }

    return summary;
  }

  _initSummary(tree) {
    const summary = {
      totalTime: totalTime(tree),
      platform: {
        name: process.platform,
      },
    };

    _getHardwareInfo(summary.platform);

    return summary;
  }

  _commandSummary(tree, commandName, commandArgs) {
    const summary = {
      name: commandName,
      args: commandArgs,
      totalTime: totalTime(tree),
      platform: {
        name: process.platform,
      },
    };

    _getHardwareInfo(summary.platform);

    return summary;
  }

  _shutdownSummary(tree) {
    const summary = {
      totalTime: totalTime(tree),
      platform: {
        name: process.platform,
      },
    };

    _getHardwareInfo(summary.platform);

    return summary;
  }

  _instrumentationFor(name) {
    let instr = this.instrumentations[name];
    if (!instr) {
      throw new Error(`No such instrumentation "${name}"`);
    }
    return instr;
  }

  _instrumentationTreeFor(name) {
    return heimdallGraph.loadFromNode(this.instrumentations[name].node);
  }

  _invokeAddonHook(name, instrumentationInfo) {
    if (this.project && this.project.addons.length) {
      this.project.addons.forEach((addon) => {
        if (typeof addon.instrumentation === 'function') {
          addon.instrumentation(name, instrumentationInfo);
        }
      });
    }
  }

  _writeInstrumentation(name, instrumentationInfo) {
    if (!vizEnabled()) {
      return;
    }

    let filename = `instrumentation.${name}`;
    if (name === 'build') {
      filename += `.${this.instrumentations.build.count}`;
    }
    filename = `${filename}.json`;
    fs.writeJsonSync(filename, {
      summary: instrumentationInfo.summary,
      // we want to change this to tree, to be consistent with the hook, but first
      // we must update broccoli-viz
      // see see https://github.com/ember-cli/broccoli-viz/issues/35
      nodes: instrumentationInfo.tree.toJSON().nodes,
    });
  }

  start(name) {
    if (!instrumentationEnabled(this.config)) {
      return;
    }

    let instr = this._instrumentationFor(name);
    this._heimdall = this._heimdall || require('heimdalljs');

    if (instr.node) {
      // don't leak nodes during build.  We have already reported on this in the
      // previous stopAndReport so no data is lost
      instr.node.remove();
    }

    let token = this._heimdall.start({ name, emberCLI: true });
    instr.token = token;
    instr.node = this._heimdall.current;
  }

  stopAndReport(name) {
    if (!instrumentationEnabled(this.config)) {
      return;
    }

    let instr = this._instrumentationFor(name);
    if (!instr.token) {
      throw new Error(`Cannot stop instrumentation "${name}".  It has not started.`);
    }
    try {
      instr.token.stop();
    } catch (e) {
      this.ui.writeLine(chalk.red(`Error reporting instrumentation '${name}'.`));
      logger.error(e.stack);
      return;
    }

    let instrSummaryName = `_${name}Summary`;
    if (!this[instrSummaryName]) {
      throw new Error(`No summary found for "${name}"`);
    }

    let tree = this._instrumentationTreeFor(name);
    let args = Array.prototype.slice.call(arguments, 1);
    args.unshift(tree);

    let instrInfo = {
      summary: this[instrSummaryName].apply(this, args),
      tree,
    };

    this._invokeAddonHook(name, instrInfo);
    this._writeInstrumentation(name, instrInfo);

    if (name === 'build') {
      instr.count++;
    }
  }
}

function totalTime(tree) {
  let totalTime = 0;
  let nodeItr;
  let node;
  let statName;
  let statValue;
  let statsItr;
  let nextNode;
  let nextStat;

  for (nodeItr = tree.dfsIterator(); ; ) {
    nextNode = nodeItr.next();
    if (nextNode.done) {
      break;
    }

    node = nextNode.value;

    for (statsItr = node.statsIterator(); ; ) {
      nextStat = statsItr.next();
      if (nextStat.done) {
        break;
      }

      statName = nextStat.value[0];
      statValue = nextStat.value[1];

      if (statName === 'time.self') {
        totalTime += statValue;
      }
    }
  }

  return totalTime;
}

// exported for testing
Instrumentation._enableFSMonitorIfInstrumentationEnabled = _enableFSMonitorIfInstrumentationEnabled;

module.exports = Instrumentation;