Repository: dbader/node-datadog-metrics (js)

Issue:
support distributions
Now that Datadog [supports](https://docs.datadoghq.com/developers/metrics/distributions/) `distribution` metric type, would be good for the library to support it too.

Pull request description:
Add support for distribution metrics
This adds support for DataDog’s `distribution` metric type, which is sort of like a histogram except is sends every point to DataDog and does the statistics in their system, rather than in the client here. (This also can work as a solution to #53 — distribution points with the same metric name and tags won’t get overwritten on DataDog’s end; that’s actually part of the whole point of distributions as I understand it, since this is the only real feasible approach for things like AWS Lambda.)

You can call it just like you would a histogram:

```js
const metrics = require("datadog-metrics");

metrics.distribution("my.metric.name", 5.3, ["a:few", "tags:here"]);
```

I tested this a few times with a live DataDog account, and it seems to work well. The way I’ve collapsed metrics from the same second gets the right results in graphs, as do out of order (time-wise) points, so we don’t need to worry about sorting.

This involves a somewhat complex change to the reporter, where it has to split the list of metrics in two (one for distributions and one for normal metrics) because distributions get sent to a different API endpoint. That seemed like the best place to do it, otherwise a lot of other stuff throughout the library would need to change in order to accommodate multiple lists of different kinds of metrics.

Fixes #46.

Patch:
diff --git a/index.js b/index.js
index dd575e0..a0d68a1 100644
--- a/index.js
+++ b/index.js
@@ -43,6 +43,7 @@ module.exports = {
     gauge: callOnSharedLogger.bind(undefined, 'gauge'),
     increment: callOnSharedLogger.bind(undefined, 'increment'),
     histogram: callOnSharedLogger.bind(undefined, 'histogram'),
+    distribution: callOnSharedLogger.bind(undefined, 'distribution'),
 
     BufferedMetricsLogger: loggers.BufferedMetricsLogger
 };
diff --git a/lib/loggers.js b/lib/loggers.js
index d8060e7..02cecf4 100644
--- a/lib/loggers.js
+++ b/lib/loggers.js
@@ -6,6 +6,7 @@ const DataDogReporter = require('./reporters').DataDogReporter;
 const Gauge = require('./metrics').Gauge;
 const Counter = require('./metrics').Counter;
 const Histogram = require('./metrics').Histogram;
+const Distribution = require('./metrics').Distribution;
 
 //
 // --- BufferedMetricsLogger
@@ -84,6 +85,10 @@ BufferedMetricsLogger.prototype.histogram = function(key, value, tags, timestamp
     this.addPoint(Histogram, key, value, tags, timestampInMillis, options);
 };
 
+BufferedMetricsLogger.prototype.distribution = function(key, value, tags, timestampInMillis) {
+    this.addPoint(Distribution, key, value, tags, timestampInMillis);
+};
+
 BufferedMetricsLogger.prototype.flush = function(onSuccess, onError) {
     const series = this.aggregator.flush();
     if (series.length > 0) {
diff --git a/lib/metrics.js b/lib/metrics.js
index 02dbd7d..2c092ae 100644
--- a/lib/metrics.js
+++ b/lib/metrics.js
@@ -185,9 +185,61 @@ Histogram.prototype.average = function() {
     }
 };
 
+//
+// --- Distribution
+//
+
+//
+// DISTRIBUTION
+// ------------
+// Similar to a histogram, but sends every point to DataDog and does the
+// calculations server-side.
+// 
+// This is higher overhead than Counter or Histogram, but is particularly useful
+// for serverless functions or other environments where many instances of your
+// application may be running concurrently or constantly starting and stopping,
+// and it does not make sense to tag each of them separately so metrics from
+// each don't overwrite each other.
+// 
+// See more documentation of use cases and how distribution work at:
+// https://docs.datadoghq.com/metrics/types/?tab=distribution#metric-types
+//
+
+function Distribution(key, tags, host) {
+    Metric.call(this, key, tags, host);
+    this.points = [];
+}
+
+util.inherits(Distribution, Metric);
+
+Distribution.prototype.addPoint = function(val, timestampInMillis) {
+    const lastTimestamp = this.timestamp;
+    this.updateTimestamp(timestampInMillis);
+    if (lastTimestamp === this.timestamp) {
+        this.points[this.points.length - 1][1].push(val);
+    } else {
+        this.points.push([this.timestamp, [val]]);
+    }
+};
+
+Distribution.prototype.flush = function() {
+    return [this.serializeMetric(this.points, 'distribution')];
+};
+
+Distribution.prototype.serializeMetric = function(points, type, key) {
+    return {
+        metric: key || this.key,
+        points: points || this.points,
+        type: type,
+        host: this.host,
+        tags: this.tags
+    };
+};
+
 module.exports = {
     Metric: Metric,
     Gauge: Gauge,
     Counter: Counter,
-    Histogram: Histogram
+    Histogram: Histogram,
+    Distribution: Distribution
 };
diff --git a/lib/reporters.js b/lib/reporters.js
index a7ca740..7e47f70 100644
--- a/lib/reporters.js
+++ b/lib/reporters.js
@@ -57,14 +57,34 @@ DataDogReporter.prototype.report = function(series, onSuccess, onError) {
 
     if (debug.enabled) {
         // Only call stringify when debugging.
-        debug('Calling add_metrics with %s', JSON.stringify(series));
+        debug('Calling report with %s', JSON.stringify(series));
     }
 
-    callbackify(() => metricsApi.submitMetrics({
-        body: {
-            series
+    // Distributions must be submitted via a different method than other
+    // metrics, so split them up.
+    const metrics = [];
+    const distributions = [];
+    for (const metric of series) {
+        if (metric.type === 'distribution') {
+            distributions.push(metric);
+        } else {
+            metrics.push(metric);
         }
-    }))(callback);
+    }
+
+    let submissions = [];
+    if (metrics.length) {
+        submissions.push(metricsApi.submitMetrics({
+            body: { series: metrics }
+        }));
+    }
+    if (distributions.length) {
+        submissions.push(metricsApi.submitDistributionPoints({
+            body: { series: distributions }
+        }));
+    }
+    
+    callbackify(() => Promise.all(submissions))(callback);
 };
 
 module.exports = {


Test patch:
diff --git a/test/metrics_tests.js b/test/metrics_tests.js
index eb59911..8a90677 100644
--- a/test/metrics_tests.js
+++ b/test/metrics_tests.js
@@ -83,7 +83,7 @@ describe('Counter', function() {
         f[0].should.have.deep.property('points[0][1]', 1);
     });
 
-    it('should flush correctly', function() {
+    it('should flush correctly when given a timestamp', function() {
         var g = new metrics.Counter('the.key', ['mytag'], 'myhost');
         g.addPoint(1, 123000);
         var f = g.flush();
@@ -221,5 +221,68 @@ describe('Histogram', function() {
         f.should.have.deep.property('[1].points[0][1]', 85);
 
     });
+});
+
+describe('Distribution', function() {
+    it('should extend Metric', function() {
+        var g = new metrics.Distribution();
+        g.updateTimestamp.should.be.a('function');
+    });
 
+    it('should flush correctly', function() {
+        var g = new metrics.Distribution('the.key', ['mytag'], 'myhost');
+        g.addPoint(1);
+        var f = g.flush();
+        f.should.have.length(1);
+        f[0].should.have.deep.property('metric', 'the.key');
+        f[0].should.have.deep.property('tags[0]', 'mytag');
+        f[0].should.have.deep.property('host', 'myhost');
+        f[0].should.have.deep.property('type', 'distribution');
+        f[0].should.have.deep.property('points[0][0]', g.timestamp);
+        f[0].should.have.deep.property('points[0][1][0]', 1);
+        f[0].points[0][1].should.have.length(1);
+    });
+
+    it('should flush correctly when given timestamp', function() {
+        var g = new metrics.Distribution('the.key', ['mytag'], 'myhost');
+        g.addPoint(1, 123000);
+        var f = g.flush();
+        f.should.have.length(1);
+        f[0].should.have.deep.property('metric', 'the.key');
+        f[0].should.have.deep.property('tags[0]', 'mytag');
+        f[0].should.have.deep.property('host', 'myhost');
+        f[0].should.have.deep.property('type', 'distribution');
+        f[0].should.have.deep.property('points[0][0]', 123);
+        f[0].should.have.deep.property('points[0][1][0]', 1);
+        f[0].points[0][1].should.have.length(1);
+    });
+
+    it('should format multiple points from different times', function () {
+        var g = new metrics.Distribution('the.key', ['mytag'], 'myhost');
+        g.addPoint(1, 123000);
+        g.addPoint(2, 125000);
+        g.addPoint(3, 121000);
+
+        var f = g.flush();
+        f.should.have.length(1);
+        f[0].points.should.eql([
+            [123, [1]],
+            [125, [2]],
+            [121, [3]]
+        ]);
+    });
+
+    it('should format multiple points from the same time', function () {
+        var g = new metrics.Distribution('the.key', ['mytag'], 'myhost');
+        g.addPoint(1, 123000);
+        g.addPoint(2, 125000);
+        g.addPoint(3, 125000);
+
+        var f = g.flush();
+        f.should.have.length(1);
+        f[0].points.should.eql([
+            [123, [1]],
+            [125, [2, 3]]
+        ]);
+    });
 });