Source: measured-core/lib/metrics/Histogram.js

const { MetricTypes } = require('./Metric');
const binarySearch = require('binary-search');
const EDS = require('../util/ExponentiallyDecayingSample');

/**
 * Keeps a reservoir of statistically relevant values biased towards the last 5 minutes to explore their distribution.
 * @implements {Metric}
 * @example
 * var Measured = require('measured')
 * var histogram = new Measured.Histogram();
 * http.createServer(function(req, res) {
 *   if (req.headers['content-length']) {
 *     histogram.update(parseInt(req.headers['content-length'], 10));
 *   }
 * });
 */
class Histogram {
  /**
   @param {HistogramProperties} [properties] see {@link HistogramProperties}.
   */
  constructor(properties) {
    this._properties = properties || {};
    this._initializeState();
  }

  _initializeState() {
    this._sample = this._properties.sample || new EDS();
    this._percentilesMethod = this._properties.percentilesMethod || this._percentiles;
    this._min = null;
    this._max = null;
    this._count = 0;
    this._sum = 0;

    // These are for the Welford algorithm for calculating running constiance
    // without floating-point doom.
    this._constianceM = 0;
    this._constianceS = 0;
  }

  /**
   * Pushes value into the sample. timestamp defaults to Date.now().
   * @param {number} value
   */
  update(value) {
    this._count++;
    this._sum += value;

    this._sample.update(value);
    this._updateMin(value);
    this._updateMax(value);
    this._updateVariance(value);
  }

  _percentiles(percentiles) {
    const values = this._sample.toArray().sort((a, b) => {
      return a === b ? 0 : a - b;
    });

    const results = {};

    let i, percentile, pos, lower, upper;
    for (i = 0; i < percentiles.length; i++) {
      percentile = percentiles[i];
      if (values.length) {
        pos = percentile * (values.length + 1);
        if (pos < 1) {
          results[percentile] = values[0];
        } else if (pos >= values.length) {
          results[percentile] = values[values.length - 1];
        } else {
          lower = values[Math.floor(pos) - 1];
          upper = values[Math.ceil(pos) - 1];
          results[percentile] = lower + (pos - Math.floor(pos)) * (upper - lower);
        }
      } else {
        results[percentile] = null;
      }
    }

    return results;
  }

  weightedPercentiles(percentiles) {
    const values = this._sample.toArrayWithWeights().sort((a, b) => {
      return a.value === b.value ? 0 : a.value - b.value;
    });

    const sumWeight = values.reduce((sum, sample) => {
      return sum + sample.priority;
    }, 0);

    const normWeights = values.map(value => {
      return value.priority / sumWeight;
    });

    const quantiles = [0];
    let i;
    for (i = 1; i < values.length; i++) {
      quantiles[i] = quantiles[i - 1] + normWeights[i - 1];
    }

    function gt(a, b) {
      return a - b;
    }

    const results = {};
    let percentile, pos;
    for (i = 0; i < percentiles.length; i++) {
      percentile = percentiles[i];
      if (values.length) {
        pos = binarySearch(quantiles, percentile, gt);
        if (pos < 0) {
          results[percentile] = values[-pos - 1 - 1].value;
        } else if (pos < 1) {
          results[percentile] = values[0].value;
        } else if (pos >= values.length) {
          results[percentile] = values[values.length - 1].value;
        }
      } else {
        results[percentile] = null;
      }
    }
    return results;
  }

  /**
   * Resets all values. Histograms initialized with custom options will be reset to the default settings (patch welcome).
   */
  reset() {
    // while this is technically a bug?, copying existing logic to maintain current api,
    // TODO reset should reset the sample, not override it with a new EDS()
    this._properties.sample = new EDS();

    this._initializeState();
  }

  /**
   * Checks whether the histogram contains values.
   * @return {boolean} Whether the histogram contains values.
   */
  hasValues() {
    return this._count > 0;
  }

  /**
   * @return {HistogramData}
   */
  toJSON() {
    const percentiles = this._percentilesMethod([0.5, 0.75, 0.95, 0.99, 0.999]);

    return {
      min: this._min,
      max: this._max,
      sum: this._sum,
      variance: this._calculateVariance(),
      mean: this._calculateMean(),
      stddev: this._calculateStddev(),
      count: this._count,
      median: percentiles[0.5],
      p75: percentiles[0.75],
      p95: percentiles[0.95],
      p99: percentiles[0.99],
      p999: percentiles[0.999]
    };
  }

  _updateMin(value) {
    if (this._min === null || value < this._min) {
      this._min = value;
    }
  }

  _updateMax(value) {
    if (this._max === null || value > this._max) {
      this._max = value;
    }
  }

  _updateVariance(value) {
    if (this._count === 1) {
      this._constianceM = value;
      return value;
    }

    const oldM = this._constianceM;

    this._constianceM += (value - oldM) / this._count;
    this._constianceS += (value - oldM) * (value - this._constianceM);

    // TODO is this right, above it returns in the if statement but does nothing but update internal state for the else case?
    return undefined;
  }

  /**
   *
   * @return {number|null}
   * @private
   */
  _calculateMean() {
    return this._count === 0 ? 0 : this._sum / this._count;
  }

  /**
   * @return {number|null}
   * @private
   */
  _calculateVariance() {
    return this._count <= 1 ? null : this._constianceS / (this._count - 1);
  }

  /**
   * @return {number|null}
   * @private
   */
  _calculateStddev() {
    return this._count < 1 ? null : Math.sqrt(this._calculateVariance());
  }

  /**
   * The type of the Metric Impl. {@link MetricTypes}.
   * @return {string} The type of the Metric Impl.
   */
  getType() {
    return MetricTypes.HISTOGRAM;
  }
}

module.exports = Histogram;

/**
 * Properties to create a {@link Histogram} with.
 *
 * @interface HistogramProperties
 * @typedef HistogramProperties
 * @type {Object}
 * @property {object} sample The sample reservoir to use. Defaults to an ExponentiallyDecayingSample.
 */

/**
 * The data returned from Histogram::toJSON()
 * @interface HistogramData
 * @typedef HistogramData
 * @typedef {object}
 * @property {number|null} min The lowest observed value.
 * @property {number|null} max The highest observed value.
 * @property {number|null} sum The sum of all observed values.
 * @property {number|null} variance The variance of all observed values.
 * @property {number|null} mean The average of all observed values.
 * @property {number|null} stddev The stddev of all observed values.
 * @property {number} count The number of observed values.
 * @property {number} median 50% of all values in the resevoir are at or below this value.
 * @property {number} p75 See median, 75% percentile.
 * @property {number} p95 See median, 95% percentile.
 * @property {number} p99 See median, 99% percentile.
 * @property {number} p999 See median, 99.9% percentile.
 */