Source: components/prometheusmetrics.js

"use strict";

/**
 * Prometheus-style /metrics gathering and exporting.
 * This class provides a central location to register gauge and counter metrics
 * used to generate the <code>/metrics</code> page.
 *
 * This class depends on having <code>prom-client</code> installed. It
 * will attempt to load this module when the constructor is invoked.
 *
 * @example <caption>A simple metric that counts the keys in an object:</caption>
 *   var metrics = new PrometheusMetrics();
 *
 *   var orange = {};
 *   metrics.addGauge({
 *       name: "oranges",
 *       help: "current number of oranges",
 *       refresh: (gauge) => {
 *           gauge.set({}, Object.keys(oranges).length);
 *       },
 *   });
 *
 * @example <caption>Generating values for multiple gauges in a single collector
 * function.</caption>
 *   var metrics = new PrometheusMetrics();
 *
 *   var oranges_gauge = metrics.addGauge({
 *       name: "oranges",
 *       help: "current number of oranges",
 *   });
 *   var apples_gauge = metrics.addGauge({
 *       name: "apples",
 *       help: "current number of apples",
 *   });
 *
 *   metrics.addCollector(() => {
 *       var counts = this._countFruit();
 *       oranges_gauge.set({}, counts.oranges);
 *       apples_gauge.set({}, counts.apples);
 *   });
 *
 * @example <caption>Using counters</caption>
 *   var metrics = new PrometheusMetrics();
 *
 *   metrics.addCollector({
 *       name: "things_made",
 *       help: "count of things that we have made",
 *   });
 *
 *   function makeThing() {
 *       metrics.incCounter("things_made");
 *       return new Thing();
 *   }
 *
 * @constructor
 */
function PrometheusMetrics() {
    // Only attempt to load these dependencies if metrics are enabled
    var client = this._client = require("prom-client");

    // prom-client 9.0.0 has to be asked explicitly; previous versions would
    //   do this by default
    if (client.collectDefaultMetrics) client.collectDefaultMetrics();

    this._collectors = []; // executed in order
    this._counters = {}; // counter metrics keyed by name
    this._timers = {}; // timer metrics (Histograms) keyed by name
}

/**
 * Registers some exported metrics that relate to operations of the embedded
 * matrix-js-sdk. In particular, a metric is added that counts the number of
 * calls to client API endpoints made by the client library.
 */
PrometheusMetrics.prototype.registerMatrixSdkMetrics = function() {
    var callCounts = this.addCounter({
        name: "matrix_api_calls",
        help: "Count of the number of Matrix client API calls made",
        labels: ["method"],
    });

    /*
     * We'll now annotate a bunch of the methods in MatrixClient to keep counts
     * of every time they're called. This seems to be neater than trying to
     * intercept all HTTP requests and try to intuit what internal method was
     * invoked based on the HTTP URL.
     * It's kindof messy to do this because we have to maintain a list of
     * client SDK method names, but the only other alternative is to hook the
     * 'request' function and attempt to parse methods out by inspecting the
     * underlying client API HTTP URLs, and that is even messier. So this is
     * the lesser of two evils.
     */

    var matrixClientPrototype = require("matrix-js-sdk").MatrixClient.prototype;

    var CLIENT_METHODS = [
        "ban",
        "createAlias",
        "createRoom",
        "getProfileInfo",
        "getStateEvent",
        "invite",
        "joinRoom",
        "kick",
        "leave",
        "register",
        "roomState",
        "sendEvent",
        "sendReceipt",
        "sendStateEvent",
        "sendTyping",
        "setAvatarUrl",
        "setDisplayName",
        "setPowerLevel",
        "setPresence",
        "setProfileInfo",
        "unban",
        "uploadContent",
    ];

    CLIENT_METHODS.forEach(function(method) {
        callCounts.inc({method: method}, 0); // initialise the count to zero

        var orig = matrixClientPrototype[method];
        matrixClientPrototype[method] = function() {
            callCounts.inc({method: method});
            return orig.apply(this, arguments);
        }
    });
};

/**
 * Registers some exported metrics that expose counts of various kinds of
 * objects within the bridge.
 * @param {BridgeGaugesCallback} counterFunc A function that when invoked
 * returns the current counts of various items in the bridge.
 */
PrometheusMetrics.prototype.registerBridgeGauges = function(counterFunc) {
    var matrixRoomsGauge = this.addGauge({
        name: "matrix_configured_rooms",
        help: "Current count of configured rooms by matrix room ID",
    });
    var remoteRoomsGauge = this.addGauge({
        name: "remote_configured_rooms",
        help: "Current count of configured rooms by remote room ID",
    });

    var matrixGhostsGauge = this.addGauge({
        name: "matrix_ghosts",
        help: "Current count of matrix-side ghost users",
    });
    var remoteGhostsGauge = this.addGauge({
        name: "remote_ghosts",
        help: "Current count of remote-side ghost users",
    });

    var matrixRoomsByAgeGauge = this.addGauge({
        name: "matrix_rooms_by_age",
        help: "Current count of matrix rooms partitioned by activity age",
        labels: ["age"],
    });
    var remoteRoomsByAgeGauge = this.addGauge({
        name: "remote_rooms_by_age",
        help: "Current count of remote rooms partitioned by activity age",
        labels: ["age"],
    });

    var matrixUsersByAgeGauge = this.addGauge({
        name: "matrix_users_by_age",
        help: "Current count of matrix users partitioned by activity age",
        labels: ["age"],
    });
    var remoteUsersByAgeGauge = this.addGauge({
        name: "remote_users_by_age",
        help: "Current count of remote users partitioned by activity age",
        labels: ["age"],
    });

    this.addCollector(function () {
        var counts = counterFunc();

        matrixRoomsGauge.set(counts.matrixRoomConfigs);
        remoteRoomsGauge.set(counts.remoteRoomConfigs);

        matrixGhostsGauge.set(counts.matrixGhosts);
        remoteGhostsGauge.set(counts.remoteGhosts);

        counts.matrixRoomsByAge.setGauge(matrixRoomsByAgeGauge);
        counts.remoteRoomsByAge.setGauge(remoteRoomsByAgeGauge);

        counts.matrixUsersByAge.setGauge(matrixUsersByAgeGauge);
        counts.remoteUsersByAge.setGauge(remoteUsersByAgeGauge);
    });
};

PrometheusMetrics.prototype.refresh = function() {
    this._collectors.forEach(function(f) { f(); });
};

/**
 * Adds a new collector function. These collector functions are run whenever
 * the /metrics page is about to be generated, allowing code to update values
 * of gauges.
 * @param {Function} func A new collector function.
 * This function is passed no arguments and is not expected to return anything.
 * It runs purely to have a side-effect on previously registered gauges.
 */
PrometheusMetrics.prototype.addCollector = function(func) {
    this._collectors.push(func);
};

/**
 * Adds a new gauge metric.
 * @param {Object} opts Options
 * @param {string=} opts.namespace An optional toplevel namespace name for the
 * new metric. Default: <code>"bridge"</code>.
 * @param {string} opts.name The variable name for the new metric.
 * @param {string} opts.help Descriptive help text for the new metric.
 * @param {Array<string>=} opts.labels An optional list of string label names
 * @param {Function=} opts.refresh An optional function to invoke to generate a
 * new value for the gauge.
 * If a refresh function is provided, it is invoked with the gauge as its only
 * parameter. The function should call the <code>set()</code> method on this
 * gauge in order to provide a new value for it.
 * @return {Gauge} A gauge metric.
 */
PrometheusMetrics.prototype.addGauge = function(opts) {
    var refresh = opts.refresh;
    var name = [opts.namespace || "bridge", opts.name].join("_");

    var gauge = new this._client.Gauge(name, opts.help, opts.labels || []);

    if (opts.refresh) {
        this._collectors.push(function() { refresh(gauge); });
    }

    return gauge;
};

/**
 * Adds a new counter metric
 * @param {Object} opts Options
 * @param {string} opts.namespace An optional toplevel namespace name for the
 * new metric. Default: <code>"bridge"</code>.
 * @param {string} opts.name The variable name for the new metric.
 * @param {string} opts.help Descriptive help text for the new metric.
 * Once created, the value of this metric can be incremented with the
 * <code>incCounter</code> method.
 * @param {Array<string>=} opts.labels An optional list of string label names
 * @return {Counter} A counter metric.
 */
PrometheusMetrics.prototype.addCounter = function(opts) {
    var name = [opts.namespace || "bridge", opts.name].join("_");

    var counter = this._counters[opts.name] =
        new this._client.Counter(name, opts.help, opts.labels || []);

    return counter;
};

/**
 * Increments the value of a counter metric
 * @param{string} name The name the metric was previously registered as.
 * @param{Object} labels Optional object containing additional label values.
 */
PrometheusMetrics.prototype.incCounter = function(name, labels) {
    if (!this._counters[name]) {
        throw new Error("Unrecognised counter metric name '" + name + "'");
    }

    this._counters[name].inc(labels);
};

/**
 * Adds a new timer metric, represented by a prometheus Histogram.
 * @param {Object} opts Options
 * @param {string} opts.namespace An optional toplevel namespace name for the
 * new metric. Default: <code>"bridge"</code>.
 * @param {string} opts.name The variable name for the new metric.
 * @param {string} opts.help Descriptive help text for the new metric.
 * @param {Array<string>=} opts.labels An optional list of string label names
 * @return {Histogram} A histogram metric.
 * Once created, the value of this metric can be incremented with the
 * <code>startTimer</code> method.
 */
PrometheusMetrics.prototype.addTimer = function(opts) {
    var name = [opts.namespace || "bridge", opts.name].join("_");

    var timer = this._timers[opts.name] =
        new this._client.Histogram(name, opts.help, opts.labels || []);

    return timer;
};

/**
 * Begins a new timer observation for a timer metric.
 * @param{string} name The name the metric was previously registered as.
 * @param{Object} labels Optional object containing additional label values.
 * @return {function} A function to be called to end the timer and report the
 * observation.
 */
PrometheusMetrics.prototype.startTimer = function(name, labels) {
    if (!this._timers[name]) {
        throw new Error("Unrecognised timer metric name '" + name + "'");
    }

    return this._timers[name].startTimer(labels);
};

/**
 * Registers the <code>/metrics</code> page generating function with the
 * containing Express app.
 * @param {Bridge} bridge The containing Bridge instance.
 */
PrometheusMetrics.prototype.addAppServicePath = function(bridge) {
    var register = this._client.register;

    bridge.addAppServicePath({
        method: "GET",
        path: "/metrics",
        handler: function(req, res) {
            this.refresh();

            try {
                var exposition = register.metrics();

                res.set("Content-Type", "text/plain");
                res.send(exposition);
            }
            catch (e) {
                res.status(500);

                res.set("Content-Type", "text/plain");
                res.send(e.toString());
            }
        }.bind(this),
    });
};

/**
 * Invoked at metrics export time to count items in the bridge.
 * @callback BridgeGaugesCallback
 * @return {BridgeGaugesCounts} An object containing counts of items in the
 * bridge.
 */

/**
 * @typedef BridgeGaugesCounts
 * @type {Object}
 * @param {number} matrixRoomConfigs The number of distinct matrix room IDs
 * known in the configuration.
 * @param {number} remoteRoomConfigs The number of distinct remote rooms known
 * in the configuration.
 * @param {number} matrixGhosts The number of matrix-side ghost users that
 * currently exist.
 * @param {number} remoteGhosts The number of remote-side ghost users that
 * currently exist.
 * @param {AgeCounters} matrixRoomsByAge The distribution of distinct matrix
 * room IDs by age of the most recently-seen message from them,
 * @param {AgeCounters} remoteRoomsByAge The distribution of distinct remote
 * rooms by age of the most recently-seen message from them.
 * @param {AgeCounters} matrixUsersByAge The distribution of distinct matrix
 * users by age of the most recently-seen message from them.
 * @param {AgeCounters} remoteUsersByAge The distribution of distinct remote
 * users by age of the most recently-seen message from them.
 */

module.exports = PrometheusMetrics;