├── LICENSE ├── README.md ├── agent ├── README.md ├── index.js ├── lib │ ├── agent.js │ ├── packet-queue.js │ └── scoped-agent.js ├── package.json └── test │ ├── agent.test.js │ ├── packet-queue.test.js │ └── scoped-agent.test.js ├── backend-pg ├── README.md ├── bin │ └── setup.js ├── index.js └── package.json ├── daemon ├── README.md ├── index.js ├── lib │ ├── aggregator │ │ ├── calibrate-interval.js │ │ ├── delta-list.js │ │ ├── index.js │ │ ├── interval-list.js │ │ └── metrics │ │ │ ├── counter.js │ │ │ ├── histogram.js │ │ │ └── llq.js │ ├── daemon.js │ ├── monitor │ │ ├── index.js │ │ ├── rule-builder.js │ │ ├── rule-tester.js │ │ ├── rule.js │ │ ├── running-mean.js │ │ └── warning.js │ ├── ring │ │ ├── http_client.js │ │ └── index.js │ └── server │ │ ├── http.js │ │ ├── metrics-stream.js │ │ ├── ring.js │ │ └── udp.js ├── package.json └── test │ ├── aggregator │ ├── calibrate-interval.test.js │ ├── delta-list.test.js │ ├── index.test.js │ ├── interval-list.test.js │ └── metrics │ │ ├── counter.test.js │ │ ├── histogram.test.js │ │ └── llq.test.js │ ├── monitor │ ├── rule-builder.test.js │ ├── rule-tester.test.js │ ├── rule.test.js │ └── running-mean.test.js │ └── server │ ├── http.test.js │ ├── metrics-stream.test.js │ ├── ring.mock.js │ └── udp.test.js └── web ├── README.md ├── app ├── index.js ├── metrics │ ├── downsample │ │ ├── counter.js │ │ ├── histogram.js │ │ ├── llquantize.js │ │ └── sample.js │ ├── flatten-llq.js │ ├── index.js │ └── interval.js ├── models │ ├── channel │ │ ├── channel.js │ │ ├── index.js │ │ └── request.js │ ├── dashboard.js │ ├── key-tree.js │ ├── mkeys.js │ ├── monitor.js │ └── tag-type.js └── routes.js ├── bin └── admin.js ├── client ├── admin │ ├── dashboard.js │ ├── index.js │ ├── keys.js │ ├── monitor.js │ ├── tag-type.js │ ├── tag.js │ └── util.js ├── api.js ├── js │ ├── api.js │ ├── history.js │ ├── index.js │ ├── models │ │ ├── channel │ │ │ ├── cached-channel.js │ │ │ ├── channel.js │ │ │ └── index.js │ │ ├── chart │ │ │ ├── chart.js │ │ │ ├── index.js │ │ │ ├── point-chart.js │ │ │ ├── set.js │ │ │ └── util.js │ │ ├── dashboard-tree.js │ │ ├── dashboard.js │ │ ├── implicit-tree.js │ │ ├── interval-loader.js │ │ ├── key-tree │ │ │ ├── dedup-key-api.js │ │ │ └── index.js │ │ ├── metrics-function.js │ │ ├── plot-settings.js │ │ ├── point-emitter.js │ │ ├── point-loader.js │ │ ├── rate-limit.js │ │ ├── router.js │ │ ├── tag-type.js │ │ └── tag.js │ ├── sail.js │ └── ui │ │ ├── chart-view-set.js │ │ ├── chart-view.js │ │ ├── chart2 │ │ ├── index.js │ │ ├── layers │ │ │ ├── axis.js │ │ │ ├── drag-zoom.js │ │ │ ├── heat.js │ │ │ ├── hover-label.js │ │ │ ├── hover-line.js │ │ │ ├── line.js │ │ │ ├── plot.js │ │ │ ├── stack.js │ │ │ └── tags.js │ │ ├── models │ │ │ ├── bounded-points.js │ │ │ ├── capped-interval.js │ │ │ ├── interval.js │ │ │ ├── layer-set.js │ │ │ ├── point-set │ │ │ │ ├── heat.js │ │ │ │ ├── llq.js │ │ │ │ ├── point-set.js │ │ │ │ ├── stack.js │ │ │ │ └── xy.js │ │ │ ├── scaling-interval.js │ │ │ ├── stack-layers.js │ │ │ └── versioned-interval.js │ │ ├── utils │ │ │ ├── format-si.js │ │ │ ├── format-time.js │ │ │ ├── raf.js │ │ │ └── translate.js │ │ └── view.js │ │ ├── dashboard-chart-view.js │ │ ├── dashboard-tree-view.js │ │ ├── index.js │ │ ├── keybindings.js │ │ ├── metrics-tree-view.js │ │ ├── settings.js │ │ ├── sidebar.js │ │ ├── tags.js │ │ ├── utils │ │ ├── gravity.js │ │ ├── scan.js │ │ ├── text-input.js │ │ └── tree.js │ │ └── views │ │ ├── chart-header.js │ │ ├── dashboard-chart-dialog.js │ │ ├── dashboard-dialog.js │ │ ├── dashboard-save-bar.js │ │ ├── dialog.js │ │ ├── input-hint.js │ │ ├── input-suggester.js │ │ ├── keybindings-dialog.js │ │ ├── link-dialog.js │ │ ├── menu-tree.js │ │ ├── menu.js │ │ ├── progress-bar.js │ │ ├── settings │ │ ├── dashboard-tree-menu.js │ │ ├── delta-picker-popup.js │ │ ├── function-popup.js │ │ ├── main-menu-popup.js │ │ ├── plot-type-popup │ │ │ ├── dashboard.js │ │ │ ├── histogram.js │ │ │ ├── index.js │ │ │ └── many.js │ │ ├── range-picker-popup.js │ │ └── settings-popup.js │ │ ├── spinner.js │ │ ├── tag-dialog.js │ │ └── time-picker.js └── stylus │ ├── base.styl │ ├── chart.styl │ ├── index.styl │ ├── mixins.styl │ └── views │ ├── chart-header.styl │ ├── dashboard-chart-dialog.styl │ ├── dashboard-dialog.styl │ ├── dashboard-save-bar.styl │ ├── dialog.styl │ ├── input-hint.styl │ ├── input-suggester.styl │ ├── keybindings-dialog.styl │ ├── link-dialog.styl │ ├── menu-tree.styl │ ├── progress-bar.styl │ ├── settings │ ├── delta-picker-popup.styl │ ├── function-popup.styl │ ├── index.styl │ ├── main-menu-popup.styl │ ├── plot-type-popup.styl │ └── range-picker-popup.styl │ ├── spinner.styl │ ├── tag-dialog.styl │ ├── time-picker.styl │ ├── tree.styl │ └── wizard.styl ├── index.js ├── lib ├── date-utils.js ├── heat.js ├── mkey.js ├── request.js ├── sort.js └── subtract-set.js ├── package.json ├── public ├── favicon.ico └── index.html └── test ├── api.test.js ├── app ├── metrics │ ├── downsample │ │ ├── counter.test.js │ │ ├── histogram.test.js │ │ └── llquantize.test.js │ ├── flatten-llq.test.js │ ├── helpers.js │ ├── index.test.js │ └── interval.test.js └── models │ ├── channel │ └── channel.test.js │ ├── dashboard.test.js │ ├── key-tree.test.js │ ├── mkeys.test.js │ └── tag-type.test.js ├── browser ├── chart │ ├── bounded-points.test.js │ ├── capped-interval.test.js │ ├── interval.test.js │ ├── layer-set.test.js │ ├── point-set │ │ ├── heat.test.js │ │ ├── llq.test.js │ │ ├── point-set.test.js │ │ ├── stack.test.js │ │ └── xy.test.js │ ├── scaling-interval.test.js │ ├── stack-layers.test.js │ └── versioned-interval.js └── models │ ├── channel │ ├── cached-channel.test.js │ ├── channel-api.mock.js │ ├── channel.test.js │ └── event-source.mock.js │ ├── chart │ ├── chart.test.js │ ├── point-chart.test.js │ ├── set.test.js │ └── util.test.js │ ├── dashboard-tree.test.js │ ├── dashboard.test.js │ ├── implicit-tree.test.js │ ├── interval-loader.test.js │ ├── key-tree │ ├── api.mock.js │ ├── dedup-key-api.test.js │ └── index.test.js │ ├── metrics-function.test.js │ ├── plot-settings.test.js │ ├── point-emitter.test.js │ ├── points.json │ ├── rate-limit.test.js │ ├── router.test.js │ └── tag.test.js ├── index.test.js └── lib ├── date-utils.test.js ├── heat.test.js ├── mkey.test.js ├── sort.test.js └── subtract-set.test.js /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Voxer IP LLC. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## [Zag](http://voxer.github.io/zag/) 2 | 3 | Zag is a fast, scalable Node.js application for aggregating and visualizing both real-time and historical metrics. 4 | 5 | * [Install/setup][setup] 6 | * [Zooming and panning][zooming-and-panning] 7 | * [Graph types][graph-types] 8 | * [Metrics keys][metrics-keys] 9 | * [Intervals][intervals] 10 | * [Deltas][deltas] 11 | * [Dashboards][dashboards] 12 | * [Tags][tags] 13 | 14 | This repo is home to the following npm packages: 15 | 16 | * [zag](https://www.npmjs.org/package/zag) _./web_ 17 | * [zag-agent](https://www.npmjs.org/package/zag-agent) _./agent_ 18 | * [zag-daemon](https://www.npmjs.org/package/zag-daemon) _./daemon_ 19 | * [zag-backend-pg](https://www.npmjs.org/package/zag-backend-pg) _./backend-pg_ 20 | 21 | Backends: 22 | 23 | * [Postgres](https://github.com/voxer/zag/tree/master/backend-pg): 24 | recommended for production. 25 | * [LevelDB](https://github.com/sentientwaffle/zag-backend-leveldb): 26 | recommended for getting started with, testing, and developing Zag. 27 | 28 | [setup]: http://voxer.github.io/zag#setup 29 | [zooming-and-panning]: http://voxer.github.io/zag#zooming-and-panning 30 | [graph-types]: http://voxer.github.io/zag#graph-types 31 | [metrics-keys]: http://voxer.github.io/zag#metrics-keys 32 | [intervals]: http://voxer.github.io/zag#intervals 33 | [deltas]: http://voxer.github.io/zag#deltas 34 | [dashboards]: http://voxer.github.io/zag#dashboards 35 | [tags]: http://voxer.github.io/zag#tags 36 | -------------------------------------------------------------------------------- /agent/README.md: -------------------------------------------------------------------------------- 1 | ## zag-agent 2 | 3 | The metrics agent sends raw points to the zag-daemons where they will 4 | be aggregated. 5 | 6 | ## API 7 | 8 | ```javascript 9 | var agent = require('zag-agent')([/* list of all metrics daemon "address:ports" */]) 10 | ``` 11 | 12 | ### `MetricsAgent#counter(String mkey[, Number value])` 13 | 14 | Increment a counter. 15 | 16 | ```javascript 17 | agent.counter("signup") 18 | ``` 19 | 20 | Increment a counter by a specific value. 21 | 22 | ```javascript 23 | agent.counter("search_results", results.length) 24 | ``` 25 | 26 | ### `MetricsAgent#histogram(String mkey, Number value)` 27 | 28 | Track a distribution of values. 29 | All histograms automatically get a heat map. 30 | 31 | ```javascript 32 | agent.histogram("HTTP_server_latency|/index.html", 123) 33 | ``` 34 | 35 | ### `MetricsAgent#scope(String scope)` 36 | 37 | Often times all of the metrics in a particular module should be scoped under 38 | the same key. `#scope(key)` returns a `MetricsAgent` that automatically prepends 39 | that key: 40 | 41 | ```javascript 42 | var latency = agent.scope("http_latency") 43 | // This is the same as `agent.counter("http_latency>/index.html")`: 44 | latency.counter("/index.html") 45 | ``` 46 | 47 | `.close()`ing a scoped agent will close the parent agent (they share a socket). 48 | 49 | ### `MetricsAgent#on("error", function(err) { })` 50 | 51 | The socket emitted an error. 52 | 53 | ### `MetricsAgent#close()` 54 | 55 | Close the socket. 56 | 57 | ```javascript 58 | agent.close() 59 | ``` 60 | -------------------------------------------------------------------------------- /agent/index.js: -------------------------------------------------------------------------------- 1 | var http = require('http') 2 | , Poolee = require('lb_pool').Pool 3 | , MetricsAgent = require('./lib/agent') 4 | , PacketQueue = require('./lib/packet-queue') 5 | 6 | module.exports = makeAgent 7 | 8 | // The daemon needs the PacketQueue 9 | makeAgent.PacketQueue = PacketQueue 10 | 11 | function makeAgent(hosts) { 12 | var pool = new Poolee(http, hosts, 13 | { max_pending: 100 14 | , ping: "/ping" 15 | , timeout: 20000 16 | , max_sockets: 5 17 | , name: "metrics" 18 | }) 19 | var agent = new MetricsAgent(pool) 20 | 21 | setTimeout(function () { 22 | if (agent.currentNode && !agent.currentNode.healthy) { 23 | agent.goOnline() 24 | } 25 | }, 1000) 26 | 27 | // Ping loop. 28 | /* 29 | ;(function pingAll() { 30 | setTimeout(function () { 31 | agent.pingAll(pingAll) 32 | }, 10000) 33 | })() 34 | */ 35 | 36 | return agent 37 | } 38 | -------------------------------------------------------------------------------- /agent/lib/packet-queue.js: -------------------------------------------------------------------------------- 1 | module.exports = PacketQueue 2 | 3 | /// Coalesce metrics packets. 4 | /// 5 | /// send(buffer, offset, length) 6 | /// options - (optional) 7 | /// * block - Integer, maximum block size 8 | /// * flush - Integer, millisecond flush interval 9 | /// * type - String, optional 10 | /// 11 | function PacketQueue(send, options) { 12 | options = options || {} 13 | this.send = send 14 | this.blockSize = options.block || 1440 15 | this.type = options.type 16 | this.prefix = this.type ? (this.type + "\n") : "" 17 | this.queue = null 18 | this.writePos = null 19 | this.reset() 20 | 21 | // Don't let stuff queue forever. 22 | var _this = this 23 | this.interval = setInterval(function() { _this.flush() }, options.flush || 1000) 24 | } 25 | 26 | PacketQueue.prototype.destroy = function() { 27 | clearInterval(this.interval) 28 | } 29 | 30 | PacketQueue.prototype.reset = function() { 31 | this.queue = [] 32 | this.writePos = this.prefix.length 33 | } 34 | 35 | PacketQueue.prototype.write = function(str) { 36 | if (this.writePos + str.length >= this.blockSize) { 37 | this.sendPacket() 38 | } 39 | this.queue.push(str) 40 | this.writePos += str.length + 1 41 | } 42 | 43 | PacketQueue.prototype.sendPacket = function() { 44 | var buf = new Buffer(this.prefix + this.queue.join("\n")) 45 | this.send(buf, 0, buf.length) 46 | this.reset() 47 | } 48 | 49 | PacketQueue.prototype.flush = function() { 50 | if (this.queue.length) this.sendPacket() 51 | } 52 | -------------------------------------------------------------------------------- /agent/lib/scoped-agent.js: -------------------------------------------------------------------------------- 1 | module.exports = ScopedAgent 2 | 3 | /// ScopedAgent mimics the public API of MetricsAgent. Any metrics sent with 4 | /// it will be prefixed with `SCOPE>`. 5 | /// 6 | /// `.close()`ing the scoped agent will close the original agent. 7 | /// 8 | /// agent - MetricsAgent 9 | /// scope - String 10 | /// 11 | function ScopedAgent(agent, scope) { 12 | this.agent = agent 13 | this.pool = agent.pool 14 | this._scope = scope 15 | } 16 | 17 | // Proxy to MetricsAgent. 18 | ;["close" 19 | , "on", "once" 20 | , "removeListener", "removeAllListeners" 21 | , "listeners", "setMaxListeners" 22 | ].forEach(function(fn) { 23 | ScopedAgent.prototype[fn] = function() { 24 | this.agent[fn].apply(this.agent, arguments) 25 | } 26 | }) 27 | 28 | // scope - String 29 | // Returns ScopedAgent 30 | ScopedAgent.prototype.scope = function(scope) { 31 | return this.agent.scope(this._scope + ">" + scope) 32 | } 33 | 34 | ScopedAgent.prototype.histogram = makeMetric("histogram") 35 | ScopedAgent.prototype.counter = makeMetric("counter") 36 | 37 | function makeMetric(fn) { 38 | return function(mkey, value) { 39 | return this.agent[fn](this._scope + ">" + mkey, value) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /agent/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zag-agent", 3 | "version": "0.0.5", 4 | "author": "sentientwaffle", 5 | "description": "send metrics to zag-daemon", 6 | "main": "index.js", 7 | "directories": { 8 | "test": "test" 9 | }, 10 | "scripts": { 11 | "test": "tap $(find test -name '*.test.js' | sort)" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git://github.com/voxer/zag.git" 16 | }, 17 | "homepage": "https://github.com/voxer/zag", 18 | "keywords": [ 19 | "zag", 20 | "metrics" 21 | ], 22 | "dependencies": { 23 | "lb_pool": "~1.0.1" 24 | }, 25 | "devDependencies": { 26 | "tap": "~0.4.6" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /agent/test/packet-queue.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , PacketQueue = require('../lib/packet-queue') 3 | 4 | function noop() {} 5 | 6 | test("PacketQueue", function(t) { 7 | var pq = new PacketQueue(noop, {block: 10}) 8 | t.equals(pq.send, noop) 9 | t.equals(pq.blockSize, 10) 10 | t.deepEquals(pq.queue, []) 11 | t.end(); pq.destroy() 12 | }) 13 | 14 | test("PacketQueue#reset", function(t) { 15 | var pq = new PacketQueue(noop, {block: 10}) 16 | pq.queue.push("foo") 17 | pq.reset() 18 | t.deepEquals(pq.queue, []) 19 | t.end(); pq.destroy() 20 | }) 21 | 22 | test("PacketQueue#write", function(t) { 23 | test("queue", function(t) { 24 | var pq = new PacketQueue(function() { t.fail() }, {block: 30}) 25 | , pos = pq.writePos 26 | pq.write("12345") 27 | t.deepEquals(pq.queue, ["12345"]) 28 | t.equals(pq.writePos, pos + 6) 29 | t.end(); pq.destroy() 30 | }) 31 | 32 | test("flush", function(t) { 33 | var blockSize = 30 34 | , w = 0 35 | , pq = new PacketQueue(send, {block: blockSize, flush: 10}) 36 | pq.write("123") 37 | pq.write("456") 38 | setTimeout(function() { 39 | t.equals(w, 1) 40 | t.end(); pq.destroy() 41 | }, 15) 42 | 43 | function send(data, offset, len) { 44 | w++ 45 | t.deepEquals(data.toString(), "123\n456") 46 | t.equals(offset, 0) 47 | t.equals(len, data.length) 48 | } 49 | }) 50 | 51 | test("overflow", function(t) { 52 | var pq = new PacketQueue(send, {block: 9}) 53 | pq.write("123") 54 | pq.write("456") 55 | pq.write("789") 56 | 57 | function send(data, offset, len) { 58 | t.deepEquals(data.toString(), "123\n456") 59 | t.equals(offset, 0) 60 | t.equals(len, "123456".length + 1) 61 | process.nextTick(function() { 62 | t.deepEquals(pq.queue, ["789"]) 63 | t.end(); pq.destroy() 64 | }) 65 | } 66 | }) 67 | 68 | t.end() 69 | }) 70 | -------------------------------------------------------------------------------- /agent/test/scoped-agent.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , EventEmitter = require('events').EventEmitter 3 | , inherits = require('util').inherits 4 | , ScopedAgent = require('../lib/scoped-agent') 5 | 6 | test("ScopedAgent", function(t) { 7 | var ma = new MockAgent() 8 | , sa = new ScopedAgent(ma, "scope") 9 | t.equals(sa.agent, ma) 10 | t.equals(sa._scope, "scope") 11 | t.end() 12 | }) 13 | 14 | test("ScopedAgent#close", function(t) { 15 | var ma = new MockAgent() 16 | , sa = new ScopedAgent(ma, "scope") 17 | sa.close() 18 | t.deepEquals(ma.ops, ["close"]) 19 | t.end() 20 | }) 21 | 22 | test("ScopedAgent#on", function(t) { 23 | var ma = new MockAgent() 24 | , sa = new ScopedAgent(ma, "scope") 25 | , error = new Error 26 | sa.on("error", function(err) { 27 | t.equals(err, error) 28 | t.end() 29 | }) 30 | ma.emit("error", error) 31 | }) 32 | 33 | test("ScopedAgent#scope", function(t) { 34 | var ma = new MockAgent() 35 | , parent = new ScopedAgent(ma, "scope1") 36 | , child = parent.scope("scope2") 37 | t.isa(child, ScopedAgent) 38 | t.equals(child.agent, ma) 39 | t.equals(child._scope, "scope1>scope2") 40 | t.end() 41 | }) 42 | 43 | ;["histogram", "counter"].forEach(function(metric) { 44 | test("ScopedAgent#" + metric, function(t) { 45 | var ma = new MockAgent() 46 | , parent = new ScopedAgent(ma, "scope1") 47 | parent[metric]("child", 1.2) 48 | t.deepEquals(ma.ops, [[metric, "scope1>child", 1.2]]) 49 | t.end() 50 | }) 51 | }) 52 | 53 | //////////////////////////////////////////////////////////////////////////////// 54 | // Helpers 55 | //////////////////////////////////////////////////////////////////////////////// 56 | 57 | function MockAgent() { this.ops = [] } 58 | 59 | inherits(MockAgent, EventEmitter) 60 | 61 | MockAgent.prototype.close = function() { this.ops.push("close") } 62 | MockAgent.prototype.scope = function(scope) { 63 | return new ScopedAgent(this, scope) 64 | } 65 | 66 | MockAgent.prototype.histogram = makeMetric("histogram") 67 | MockAgent.prototype.counter = makeMetric("counter") 68 | 69 | function makeMetric(fn) { 70 | return function(mkey, value) { this.ops.push([fn, mkey, value]) } 71 | } 72 | -------------------------------------------------------------------------------- /backend-pg/README.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | The setup script will create the tables and indices. As of 1.0.0 this requires [TimescaleDB](https://github.com/timescale/timescaledb) to be installed. 4 | 5 | # Print usage. 6 | $ ./bin/setup.js 7 | -------------------------------------------------------------------------------- /backend-pg/bin/setup.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var path = require('path') 4 | , psql = require('pg') 5 | , Backend = require('../') 6 | 7 | var argv = process.argv 8 | , args = argv.slice(2) 9 | , host = args[0] 10 | , env = args[1] 11 | , l = console.log 12 | 13 | if (!host || !env) usage() 14 | 15 | var ms = new Backend( 16 | { db: host 17 | , env: env 18 | }) 19 | 20 | ms.setup(function(err) { 21 | if (err) throw err 22 | ms.close() 23 | }) 24 | 25 | function usage() { 26 | l("Usage: " + argv[0] + " " + path.basename(argv[1]) + " ") 27 | process.exit() 28 | } 29 | -------------------------------------------------------------------------------- /backend-pg/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zag-backend-pg", 3 | "version": "1.0.0", 4 | "author": "sentientwaffle", 5 | "description": "postgres backend for zag metrics", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git://github.com/voxer/zag.git" 13 | }, 14 | "homepage": "https://github.com/voxer/zag", 15 | "keywords": [ 16 | "zag", 17 | "metrics" 18 | ], 19 | "dependencies": { 20 | "pg": "7.4.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /daemon/README.md: -------------------------------------------------------------------------------- 1 | ## zag-daemon 2 | 3 | The daemons aggregate the raw points sent by [`zag-agent`][agent]. 4 | 5 | It is also responsible for monitoring and alerting, though that functionality 6 | is disabled for now. 7 | 8 | ## Service setup 9 | 10 | In order to scale, metrics data can be spread across multiple daemons that are 11 | configured as a ring. A list of their `address:port`s needs to be passed in 12 | as the `join` option. 13 | 14 | ```javascript 15 | require('zag-daemon')( 16 | { host: "address:port" 17 | , join: ["address:port"] 18 | , db: "postgres://postgres:1234@localhost/postgres" 19 | , env: "prod" or "dev" 20 | , backend: require('zag-backend-pg') 21 | }).on("error", function(err) { }) 22 | ``` 23 | 24 | ## API 25 | 26 | [zag-agent][agent] uses the UDP API. 27 | 28 | ### UDP 29 | 30 | The primary protocol for recording metrics is over UDP. Each daemon is running a UDP 31 | server on `options.host`. 32 | 33 | The data should be newline-delimited lines of the form: 34 | 35 | := 36 | 37 | where 38 | 39 | * `type` - `counter` or `histogram`. 40 | * `key` - `[>| \w/._()+:-]+` 41 | * `value` - Number. Positive or negative, integer or decimal. 42 | 43 | ### HTTP 44 | #### `POST /api/metrics` 45 | 46 | The POST body should be in the same format as the UDP data. The points are 47 | recorded as the arrive, so the client can just keep sending data down a 48 | single connection instead of making repeated requests. 49 | 50 | [agent]: https://github.com/Voxer/zag/tree/master/agent 51 | -------------------------------------------------------------------------------- /daemon/index.js: -------------------------------------------------------------------------------- 1 | var MetricsDaemon = require('./lib/daemon') 2 | 3 | module.exports = function(options) { return new MetricsDaemon(options) } 4 | -------------------------------------------------------------------------------- /daemon/lib/aggregator/calibrate-interval.js: -------------------------------------------------------------------------------- 1 | /// Like `setInterval`, but calibrate to the interval. 2 | /// 3 | /// For example, if it is 8:32:21, and `delay` is 60000 (1 minutes), 4 | /// the first call of `fn` will occur at 8:33:00. The next call will be at 5 | /// 8:34:00, and so on. 6 | /// 7 | /// fn - Function 8 | /// delay - Integer, milliseconds. The interval. 9 | /// 10 | /// Returns Function. Calling will clear the timers. 11 | module.exports = function(fn, delay) { 12 | var interval 13 | var timeout = setTimeout(function() { 14 | timeout = null 15 | interval = setInterval(fn, delay) 16 | }, delay - Date.now() % delay) 17 | 18 | return function() { 19 | if (timeout) clearTimeout(timeout) 20 | if (interval) clearInterval(interval) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /daemon/lib/aggregator/delta-list.js: -------------------------------------------------------------------------------- 1 | module.exports = DeltaList 2 | 3 | /// Maintain a set of numbers for each metrics key. 4 | /// Most of the time a metric only needs to track the default delta, 60000. 5 | /// When a live metric is registered, though, it may have other values. 6 | /// 7 | /// defaultI - Integer 8 | /// 9 | function DeltaList(defaultI) { 10 | this.deltas = {} // { mkey : [Integer] } 11 | this.defaultI = defaultI 12 | this.defaultList = [defaultI] 13 | } 14 | 15 | // mkey - String 16 | // Returns [Integer] 17 | DeltaList.prototype.get = function(mkey) { 18 | return this.deltas[mkey] || this.defaultList 19 | } 20 | 21 | // mkey - String 22 | // delta - Integer 23 | DeltaList.prototype.add = function(mkey, delta) { 24 | if (delta === this.defaultI) return 25 | var deltas = this.deltas[mkey] 26 | if (deltas) { 27 | if (deltas.indexOf(delta) === -1) { 28 | deltas.push(delta) 29 | } 30 | } else { 31 | this.deltas[mkey] = [this.defaultI, delta] 32 | } 33 | } 34 | 35 | // mkey - String 36 | // delta - Integer 37 | DeltaList.prototype.remove = function(mkey, delta) { 38 | if (delta === this.defaultI) return 39 | var deltas = this.deltas[mkey] 40 | if (!deltas) return 41 | 42 | var index = deltas.indexOf(delta) 43 | if (index === -1) return 44 | 45 | // 1 is 60000, the other is being removed. 46 | if (deltas.length === 2) { 47 | delete this.deltas[mkey] 48 | } else { 49 | deltas.splice(index, 1) 50 | } 51 | } 52 | 53 | // Returns Integer, number of keys being listened to. 54 | DeltaList.prototype.size = function() { 55 | return Object.keys(this.deltas).length 56 | } 57 | -------------------------------------------------------------------------------- /daemon/lib/aggregator/interval-list.js: -------------------------------------------------------------------------------- 1 | module.exports = IntervalList 2 | 3 | /// Maintain 1 interval function for each interesting delta. 4 | function IntervalList() { 5 | this.intervals = {} // { delta : Interval } 6 | this.counts = {} // { delta : Integer } 7 | } 8 | 9 | // Clear all intervals. For testing only. 10 | IntervalList.prototype.destroy = function() { 11 | var deltas = Object.keys(this.intervals) 12 | for (var i = 0; i < deltas.length; i++) { 13 | this.clearInterval(deltas[i]) 14 | } 15 | } 16 | 17 | // Get whether an interval already is bound to the delta. 18 | // 19 | // delta - Integer, milliseconds 20 | // 21 | // Returns Boolean. 22 | IntervalList.prototype.has = function(delta) { 23 | return !!this.counts[delta] 24 | } 25 | 26 | // Add/remove a waiter. When the number of waiters hits zero, 27 | // the interval is cleared. 28 | IntervalList.prototype.incr = function(delta) { this.counts[delta]++ } 29 | IntervalList.prototype.decr = function(delta) { 30 | if (--this.counts[delta] === 0) { 31 | this.clearInterval(delta) 32 | } 33 | } 34 | 35 | // Attach an interval to the given function. 36 | // Before calling, check that `.has(delta)` returns false. 37 | // 38 | // fn - Function 39 | // delta - Integer, milliseconds 40 | // 41 | IntervalList.prototype.setInterval = function(fn, delta) { 42 | this.intervals[delta] = setInterval(fn, delta) 43 | this.counts[delta] = 0 44 | this.incr(delta) 45 | } 46 | 47 | // Internal: Clear the interval associated with the given delta. 48 | // 49 | // delta - Integer, milliseconds 50 | // 51 | IntervalList.prototype.clearInterval = function(delta) { 52 | var interval = this.intervals[delta] 53 | if (interval) { 54 | clearInterval(interval) 55 | this.intervals[delta] = null 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /daemon/lib/aggregator/metrics/counter.js: -------------------------------------------------------------------------------- 1 | module.exports = Counter 2 | 3 | function Counter() { this.count = 0 } 4 | 5 | Counter.prototype.push = function(v) { this.count += v } 6 | 7 | Counter.prototype.toJSON = function(ts) { 8 | return { ts: ts, count: this.count } 9 | } 10 | -------------------------------------------------------------------------------- /daemon/lib/aggregator/metrics/histogram.js: -------------------------------------------------------------------------------- 1 | var MHistogram = require('metrics').Histogram 2 | 3 | module.exports = Histogram 4 | 5 | function Histogram() { this.hist = new MHistogram() } 6 | 7 | Histogram.prototype.push = function(val) { this.hist.update(val) } 8 | 9 | Histogram.prototype.toJSON = function(ts) { 10 | var hist = this.hist 11 | , percentiles = hist.percentiles([0.1, 0.5, 0.75, 0.95, 0.99]) 12 | return { ts: ts 13 | , count: hist.count 14 | , max: hist.max 15 | , mean: r(hist.mean()) 16 | , std_dev: r(hist.stdDev()) || 0 17 | , p10: r(percentiles[0.1]) 18 | , median: r(percentiles[0.5]) 19 | , p75: r(percentiles[0.75]) 20 | , p95: r(percentiles[0.95]) 21 | , p99: r(percentiles[0.99]) 22 | } 23 | } 24 | 25 | Histogram.prototype.getMean = function() { 26 | return this.hist.mean() || 0 27 | } 28 | 29 | Histogram.prototype.getVariance = function() { 30 | return this.hist.variance() || 0 31 | } 32 | 33 | // Strip some digits 34 | function r(num) { 35 | return num === Math.floor(num) ? num 36 | : (Math.floor(num * 10000) / 10000) 37 | } 38 | -------------------------------------------------------------------------------- /daemon/lib/aggregator/metrics/llq.js: -------------------------------------------------------------------------------- 1 | var llquantize = require('llquantize') 2 | 3 | module.exports = LLQ 4 | 5 | function LLQ() { this.llq = llquantize(2, 16) } 6 | 7 | LLQ.prototype.push = function(val) { this.llq(val) } 8 | 9 | LLQ.prototype.toJSON = function(ts) { 10 | return { ts: ts, data: this.llq() } 11 | } 12 | -------------------------------------------------------------------------------- /daemon/lib/monitor/index.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter 2 | , inherits = require('util').inherits 3 | , RuleBuilder = require('./rule-builder') 4 | , RuleTester = require('./rule-tester') 5 | , reIgnore = /(@llq$|[|])/ 6 | 7 | module.exports = MetricsMonitor 8 | 9 | var passTest = 10 | { test: function() { return [] } 11 | , isExpired: function() { return false } 12 | } 13 | 14 | /// 15 | /// db - Backend 16 | /// 17 | /// Events: 18 | /// * warn([Warning]) 19 | /// * error(err) 20 | /// 21 | function MetricsMonitor(db) { 22 | this.db = db 23 | this.tests = {} // { mkey : RuleTester } 24 | this.rules = new RuleBuilder(db, 7 * 24 * 60 * 60 * 1000) 25 | } 26 | 27 | inherits(MetricsMonitor, EventEmitter) 28 | 29 | // points - {mkey : point} 30 | MetricsMonitor.prototype.test = function(points) { 31 | var mkeys = Object.keys(points) 32 | , warnings = [] 33 | for (var i = 0; i < mkeys.length; i++) { 34 | var mkey = mkeys[i] 35 | if (reIgnore.test(mkey)) continue 36 | var tester = this.getTester(mkey) 37 | , warns = tester.test(points[mkey]) 38 | for (var j = 0; j < warns.length; j++) { warnings.push(warns[j]) } 39 | 40 | if (tester.isExpired()) { 41 | this.tests[mkey] = null 42 | } 43 | } 44 | this.emit("warn", warnings) 45 | return warnings 46 | } 47 | 48 | 49 | //////////////////////////////////////////////////////////////////////////////// 50 | // Internal 51 | //////////////////////////////////////////////////////////////////////////////// 52 | 53 | // mkey - String 54 | // Returns RuleTester 55 | MetricsMonitor.prototype.getTester = function(mkey) { 56 | var tester = this.tests[mkey] 57 | if (!tester) this.loadRule(mkey) 58 | return tester || passTest 59 | } 60 | 61 | // mkey - String 62 | MetricsMonitor.prototype.loadRule = function(mkey) { 63 | var _this = this 64 | this.tests[mkey] = passTest 65 | this.rules.get(mkey, function(err, rule) { 66 | // This is before the error event because even if `rule` isn't defined 67 | // we want to remove `passTest` from `this.tests` so that it will 68 | // be retried. 69 | _this.tests[mkey] = rule && new RuleTester(mkey, rule) 70 | if (err) { 71 | _this.emit("error", err) 72 | } 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /daemon/lib/monitor/rule-tester.js: -------------------------------------------------------------------------------- 1 | var RunningMean = require('./running-mean') 2 | , Warning = require('./warning') 3 | 4 | module.exports = RuleTester 5 | 6 | /// A RuleTester receives points and outputs warnings. 7 | /// 8 | /// mkey - String 9 | /// rule - Rule 10 | /// 11 | function RuleTester(mkey, rule) { 12 | this.mkey = mkey 13 | this.rule = rule 14 | this.dmeans = {} // { field : RunningMean } 15 | } 16 | 17 | // point - {count} or {mean, median, ...} 18 | // Returns [Warning] 19 | RuleTester.prototype.test = function(point) { 20 | var warnings = [] 21 | , fields = this.rule.fields 22 | for (var i = 0; i < fields.length; i++) { 23 | var field = fields[i] 24 | , value = point[field] 25 | if (value !== undefined) { 26 | this.getDMean(field).push(value) 27 | var warn = this.check(field) 28 | if (warn) warnings.push(warn) 29 | } 30 | } 31 | return warnings 32 | } 33 | 34 | RuleTester.prototype.isExpired = function() { 35 | return this.rule.isExpired() 36 | } 37 | 38 | //////////////////////////////////////////////////////////////////////////////// 39 | // Internal 40 | //////////////////////////////////////////////////////////////////////////////// 41 | 42 | // field - String "count" or "mean" 43 | // Returns Warning or undefined 44 | RuleTester.prototype.check = function(field) { 45 | var rpoint = this.dmeans[field] 46 | , bounds = this.rule.hours[getHour()] 47 | 48 | if (!bounds) return 49 | 50 | var value = rpoint.mean 51 | , target = bounds[field] 52 | , margin = Math.max(1, bounds[field + "_var"]) 53 | 54 | if (value > target + margin) { 55 | return new Warning(this.mkey, field, value, target, margin, ">") 56 | } 57 | 58 | if (value < target - margin) { 59 | return new Warning(this.mkey, field, value, target, margin, "<") 60 | } 61 | } 62 | 63 | // field - String "count" or "mean" 64 | // Returns RunningMean 65 | RuleTester.prototype.getDMean = function(field) { 66 | return this.dmeans[field] 67 | || (this.dmeans[field] = new RunningMean(0.2)) 68 | } 69 | 70 | function getHour() { return (new Date).getHours() } 71 | -------------------------------------------------------------------------------- /daemon/lib/monitor/rule.js: -------------------------------------------------------------------------------- 1 | var msWeek = 7 * 24 * 60 * 60 * 1000 2 | 3 | module.exports = Rule 4 | 5 | /// 6 | /// opts - 7 | /// ts - Integer timestamp. 8 | /// fields - [String], a subset of FIELDS. 9 | /// hours - { hour : { count, count_var[, mean, mean_var] } } 10 | /// 11 | function Rule(opts) { 12 | this.ts = opts.ts 13 | this.fields = opts.fields 14 | this.hours = opts.hours 15 | } 16 | 17 | Rule.interval = msWeek 18 | 19 | // Return true when the rule needs to be re-generated with the latest points. 20 | // 21 | // Returns Boolean 22 | Rule.prototype.isExpired = function() { 23 | return (Date.now() - this.ts > msWeek) 24 | || (!!this.fields.length && !this.hours[getHour()]) 25 | } 26 | 27 | function getHour() { return (new Date).getHours() } 28 | -------------------------------------------------------------------------------- /daemon/lib/monitor/running-mean.js: -------------------------------------------------------------------------------- 1 | module.exports = RunningMean 2 | 3 | function RunningMean(decay) { 4 | this.decay = decay && (1 / decay) 5 | this.mean = 0 6 | this.count = 1 7 | } 8 | 9 | RunningMean.prototype.push = function(value) { 10 | var count = this.count 11 | , d = this.decay || count 12 | this.mean = count === 1 ? value : (value + this.mean * (d - 1)) / d 13 | this.count++ 14 | } 15 | -------------------------------------------------------------------------------- /daemon/lib/monitor/warning.js: -------------------------------------------------------------------------------- 1 | module.exports = Warning 2 | 3 | /// 4 | /// mkey - String metrics key 5 | /// field - String "count" or "mean" 6 | /// value - Number, current value 7 | /// target - Number, ideal value 8 | /// margin - Number, the margin on either side of the `target`. 9 | /// cmp - String, ">" or "<". ">" means too high, "<" is too low. 10 | /// 11 | function Warning(mkey, field, value, target, margin, cmp) { 12 | this.mkey = mkey 13 | this.field = field 14 | this.value = value 15 | this.target = target 16 | this.margin = margin 17 | this.cmp = cmp 18 | } 19 | -------------------------------------------------------------------------------- /daemon/lib/ring/http_client.js: -------------------------------------------------------------------------------- 1 | module.exports = Client 2 | 3 | function Client(req, res) { 4 | this.req = req 5 | this.res = res 6 | } 7 | 8 | Client.prototype.send_header = function(code) { 9 | this.res.writeHead(code) 10 | } 11 | 12 | Client.prototype.res_write = function(data) { 13 | this.res.write(data) 14 | } 15 | 16 | Client.prototype.res_end = function(data) { 17 | if (data) this.res_write(data) 18 | this.res.end() 19 | } 20 | -------------------------------------------------------------------------------- /daemon/lib/server/metrics-stream.js: -------------------------------------------------------------------------------- 1 | var Stream = require('stream') 2 | , inherits = require('util').inherits 3 | , reMetric = /^(\w+):([^=]+)=(\-?\d+\.?\d*)$/ 4 | 5 | module.exports = MetricsStream 6 | 7 | /// onMetrics([{type, key, value}]) 8 | function MetricsStream(onMetrics) { 9 | this.onMetrics = onMetrics 10 | this.buffer = "" 11 | this.writable = true 12 | } 13 | 14 | MetricsStream.parse = parseMetrics 15 | 16 | inherits(MetricsStream, Stream) 17 | 18 | MetricsStream.prototype.write = function(data) { 19 | this.buffer += data.toString() 20 | this.flush() 21 | } 22 | 23 | MetricsStream.prototype.end = function() { 24 | this.buffer += "\n" 25 | this.flush() 26 | this.destroy() 27 | } 28 | 29 | MetricsStream.prototype.destroy = function() { 30 | this.onMetrics = null 31 | this.buffer = "" 32 | this.writable = false 33 | } 34 | 35 | MetricsStream.prototype.flush = function() { 36 | var data = this.buffer 37 | , end = data.lastIndexOf("\n") 38 | if (end === -1) return 39 | 40 | var metrics = parseMetrics(data.slice(0, end)) 41 | if (metrics.length) this.onMetrics(metrics) 42 | 43 | this.buffer = end === data.length - 1 ? "" : data.slice(end) 44 | } 45 | 46 | 47 | // message - String, "\n"-delimited metrics. 48 | // Returns [{type, key, value}] 49 | function parseMetrics(message) { 50 | var metricStrs = message.split("\n") 51 | , metrics = [] 52 | for (var i = 0; i < metricStrs.length; i++) { 53 | var match = reMetric.exec(metricStrs[i]) 54 | if (match) { 55 | metrics.push({type: match[1], key: match[2], value: +match[3]}) 56 | } 57 | } 58 | return metrics 59 | } 60 | -------------------------------------------------------------------------------- /daemon/lib/server/udp.js: -------------------------------------------------------------------------------- 1 | var parseMetrics = require('./metrics-stream').parse 2 | 3 | module.exports = MetricsUDPServer 4 | 5 | /// ring - MetricsRing 6 | function MetricsUDPServer(ring) { 7 | this.ring = ring 8 | } 9 | 10 | // bulkMessage - Buffer 11 | // rinfo - Object 12 | MetricsUDPServer.prototype.onMessage = function(bulkMessage, rinfo) { 13 | var message = parseBulkMessage(bulkMessage) 14 | if (!message) return 15 | var metrics = parseMetrics(message.data) 16 | if (metrics.length) { 17 | this.ring.metrics(metrics, message.type === "RB") 18 | } 19 | } 20 | 21 | // Returns UDPMessage or nothing. 22 | function parseBulkMessage(buffer) { 23 | var str = buffer.toString("utf8") 24 | , type = str.slice(0, 3) === "RB\n" ? "RB" : "" 25 | , offset = type ? (type.length + 1) : 0 26 | return (type === "" || type === "RB") && new UDPMessage(type, str.slice(offset)) 27 | } 28 | 29 | function UDPMessage(type, data) { 30 | this.type = type 31 | this.data = data 32 | } 33 | -------------------------------------------------------------------------------- /daemon/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zag-daemon", 3 | "version": "0.0.2", 4 | "author": "sentientwaffle", 5 | "description": "aggregate metrics data", 6 | "main": "index.js", 7 | "directories": { 8 | "test": "test" 9 | }, 10 | "scripts": { 11 | "test": "tap $(find test -name '*.test.js' | sort)" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git://github.com/voxer/zag.git" 16 | }, 17 | "homepage": "https://github.com/voxer/zag", 18 | "keywords": [ 19 | "zag", 20 | "metrics" 21 | ], 22 | "dependencies": { 23 | "metrics": "~0.1.6", 24 | "llquantize": "0.0.6", 25 | "routes": "~0.2.0", 26 | "zag-agent": "~0.0.0" 27 | }, 28 | "devDependencies": { 29 | "tap": "~0.4.6" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /daemon/test/aggregator/calibrate-interval.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , calIv = require('../../lib/aggregator/calibrate-interval') 3 | 4 | test("calibrateInterval", function(t) { 5 | var i = 0 6 | var clear = calIv(function() { 7 | i++ 8 | }, 5) 9 | 10 | setTimeout(function() { 11 | t.ok(4 < i < 6) 12 | var before = i 13 | clear() 14 | setTimeout(function() { 15 | t.equals(i, before) 16 | t.end() 17 | }, 20) 18 | }, 20) 19 | }) 20 | 21 | test("calibrateInterval calibration", function(t) { 22 | var i = 0 23 | var clear = calIv(function() { 24 | var now = new Date() 25 | , off = now.getMilliseconds() % 100 26 | 27 | t.ok(off < 15 || off > 85) 28 | if (i++ === 5) { 29 | clear() 30 | t.end() 31 | } 32 | }, 100) 33 | }) 34 | 35 | test("calibrateInterval clear immediately", function(t) { 36 | var clear = calIv(function() { t.fail() }) 37 | clear() 38 | setTimeout(function() { t.end() }, 20) 39 | }) 40 | -------------------------------------------------------------------------------- /daemon/test/aggregator/delta-list.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , DeltaList = require('../../lib/aggregator/delta-list') 3 | , MIN = 60000 4 | 5 | test("DeltaList", function(t) { 6 | var dl = new DeltaList(MIN) 7 | t.deepEquals(dl.deltas, {}) 8 | t.equals(dl.defaultI, MIN) 9 | t.deepEquals(dl.defaultList, [MIN]) 10 | t.end() 11 | }) 12 | 13 | test("DeltaList#get", function(t) { 14 | var dl = new DeltaList(MIN) 15 | t.equals(dl.get("foo"), dl.defaultList) 16 | dl.add("foo", 5) 17 | t.deepEquals(dl.get("foo"), [MIN, 5]) 18 | t.end() 19 | }) 20 | 21 | test("DeltaList#add", function(t) { 22 | var dl = new DeltaList(MIN) 23 | dl.add("foo", 5) 24 | dl.add("foo", 5) // add duplicate is a noop 25 | dl.add("foo", 6) 26 | t.deepEquals(dl.deltas, {foo: [MIN, 5, 6]}) 27 | t.end() 28 | }) 29 | 30 | test("DeltaList#remove", function(t) { 31 | var dl = new DeltaList(MIN) 32 | dl.add("foo", 5) 33 | dl.add("foo", 6) 34 | dl.add("foo", 7) 35 | dl.remove("foo", 6) 36 | t.deepEquals(dl.deltas, {foo: [MIN, 5, 7]}) 37 | 38 | dl.remove("foo", 7) 39 | dl.remove("foo", MIN) // cant remove the default 40 | dl.remove("foo", 1000) // remove a bogus delta 41 | t.deepEquals(dl.deltas, {foo: [MIN, 5]}) 42 | 43 | dl.remove("foo", 5) // remove the last one 44 | dl.remove("bar", 1000) // remove a bogus delta (2) 45 | t.deepEquals(dl.deltas, {}) 46 | t.end() 47 | }) 48 | 49 | test("DeltaList#size", function(t) { 50 | var dl = new DeltaList(MIN) 51 | t.equals(dl.size(), 0) 52 | 53 | dl.add("foo", 5) 54 | t.equals(dl.size(), 1) 55 | dl.add("foo", 6) 56 | t.equals(dl.size(), 1) 57 | 58 | dl.add("bar", 5) 59 | t.equals(dl.size(), 2) 60 | dl.remove("bar", 5) 61 | t.equals(dl.size(), 1) 62 | 63 | t.end() 64 | }) 65 | -------------------------------------------------------------------------------- /daemon/test/aggregator/interval-list.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , IntervalList = require('../../lib/aggregator/interval-list') 3 | 4 | function noop() {} 5 | 6 | test("IntervalList", function(t) { 7 | var intervals = new IntervalList() 8 | t.deepEquals(intervals.intervals, {}) 9 | t.deepEquals(intervals.counts, {}) 10 | t.end() 11 | }) 12 | 13 | test("IntervalList#has", function(t) { 14 | var intervals = new IntervalList() 15 | t.equals(intervals.has(5), false) 16 | intervals.setInterval(noop, 5) 17 | t.equals(intervals.has(5), true) 18 | intervals.decr(5) 19 | t.equals(intervals.has(5), false) 20 | 21 | t.deepEquals(intervals.intervals, {5: null}) 22 | t.deepEquals(intervals.counts, {5: 0}) 23 | t.end() 24 | }) 25 | 26 | test("IntervalList#incr", function(t) { 27 | var intervals = new IntervalList() 28 | intervals.setInterval(noop, 5) 29 | t.deepEquals(intervals.counts, {5: 1}) 30 | intervals.incr(5) 31 | t.deepEquals(intervals.counts, {5: 2}) 32 | t.end(); intervals.destroy() 33 | }) 34 | 35 | test("IntervalList#decr", function(t) { 36 | var intervals = new IntervalList() 37 | intervals.setInterval(noop, 5) 38 | intervals.incr(5) 39 | intervals.decr(5) 40 | t.deepEquals(intervals.counts, {5: 1}) 41 | intervals.decr(5) 42 | t.deepEquals(intervals.intervals, {5: null}) 43 | t.deepEquals(intervals.counts, {5: 0}) 44 | t.end() 45 | }) 46 | 47 | test("IntervalList#setInterval", function(t) { 48 | var intervals = new IntervalList() 49 | , start = Date.now() 50 | , i = 0 51 | intervals.setInterval(function() { 52 | if (i++ === 20) { 53 | t.equals(Math.round((Date.now() - start) / i), 5) 54 | intervals.destroy() 55 | t.end() 56 | } 57 | }, 5) 58 | }) 59 | -------------------------------------------------------------------------------- /daemon/test/aggregator/metrics/counter.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , Counter = require('../../../lib/aggregator/metrics/counter') 3 | 4 | test("Counter", function(t) { 5 | var c = new Counter() 6 | t.equals(c.count, 0) 7 | t.end() 8 | }) 9 | 10 | test("Counter#push", function(t) { 11 | var c = new Counter() 12 | c.push(10) 13 | t.equals(c.count, 10) 14 | c.push(-5) 15 | t.equals(c.count, 5) 16 | t.end() 17 | }) 18 | 19 | test("Counter#toJSON", function(t) { 20 | var c = new Counter() 21 | c.push(12) 22 | t.deepEquals(c.toJSON(123), 23 | { ts: 123 24 | , count: 12 25 | }) 26 | t.end() 27 | }) 28 | -------------------------------------------------------------------------------- /daemon/test/aggregator/metrics/histogram.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , Histogram = require('../../../lib/aggregator/metrics/histogram') 3 | 4 | test("Histogram", function(t) { 5 | var h = new Histogram() 6 | t.ok(h.hist) 7 | t.end() 8 | }) 9 | 10 | test("Histogram#push, Histogram#toJSON", function(t) { 11 | var h = new Histogram() 12 | h.push(0) 13 | h.push(0.5) 14 | h.push(1) 15 | t.deepEquals(h.toJSON(123), 16 | { ts: 123 17 | , count: 3 18 | , max: 1 19 | , mean: 0.5 20 | , std_dev: 0.5 21 | , p10: 0 22 | , median: 0.5 23 | , p75: 1 24 | , p95: 1 25 | , p99: 1 26 | }) 27 | t.end() 28 | }) 29 | 30 | test("Histogram#push one", function(t) { 31 | var h = new Histogram() 32 | h.push(5) 33 | t.equals(h.toJSON(123).std_dev, 0) 34 | t.end() 35 | }) 36 | 37 | test("Histogram#getMean", function(t) { 38 | var h = new Histogram() 39 | t.equals(h.getMean(), 0) 40 | h.push(10) 41 | t.equals(h.getMean(), 10) 42 | h.push(20) 43 | t.equals(h.getMean(), 15) 44 | t.end() 45 | }) 46 | 47 | test("Histogram#getVariance", function(t) { 48 | var h = new Histogram() 49 | t.equals(h.getVariance(), 0) 50 | h.push(0) 51 | t.equals(h.getVariance(), 0) 52 | h.push(10) 53 | h.push(20) 54 | t.equals(h.getVariance(), 100) 55 | t.end() 56 | }) 57 | -------------------------------------------------------------------------------- /daemon/test/aggregator/metrics/llq.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , LLQ = require('../../../lib/aggregator/metrics/llq') 3 | 4 | test("LLQ", function(t) { 5 | var l = new LLQ() 6 | t.ok(l.llq) 7 | t.end() 8 | }) 9 | 10 | test("LLQ#push, LLQ#toJSON", function(t) { 11 | var l = new LLQ() 12 | l.push(0) 13 | l.push(1) 14 | l.push(16) 15 | l.push(64) 16 | l.push(65) 17 | l.push(130) 18 | 19 | t.deepEquals(l.toJSON(123), 20 | { ts: 123 21 | , data: 22 | { 0: 1 23 | , 1: 1 24 | , 16: 1 25 | , 64: 2 26 | , 128: 1 27 | } 28 | }) 29 | t.end() 30 | }) 31 | -------------------------------------------------------------------------------- /daemon/test/monitor/rule-builder.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , RuleBuilder = require('../../lib/monitor/rule-builder') 3 | , Rule = require('../../lib/monitor/rule') 4 | 5 | test("RuleBuilder", function(t) { 6 | var db = new RuleDB({}) 7 | , rb = new RuleBuilder(db, 1000) 8 | t.equals(rb.db, db) 9 | t.equals(rb.wayback, 1000) 10 | t.end() 11 | }) 12 | 13 | test("RuleBuilder#get valid", function(t) { 14 | var db = { rules: {ts: Date.now(), fields: ["mean"], hours: makeHours()} } 15 | , rb = new RuleBuilder(new RuleDB(db), 1000) 16 | rb.get("key", function(err, rule) { 17 | if (err) throw err 18 | t.isa(rule, Rule) 19 | t.end() 20 | }) 21 | }) 22 | 23 | test("RuleBuilder#get build", function(t) { 24 | var db = { points: makePoints(1440) } 25 | , rb = new RuleBuilder(new RuleDB(db), 1000) 26 | rb.get("key", function(err, rule) { 27 | if (err) throw err 28 | t.ok(db.rules) 29 | t.isa(rule, Rule) 30 | t.deepEquals(rule.fields, ["count"]) 31 | t.end() 32 | }) 33 | }) 34 | 35 | //////////////////////////////////////////////////////////////////////////////// 36 | // Helpers 37 | //////////////////////////////////////////////////////////////////////////////// 38 | 39 | function makePoints(n) { 40 | var points = [] 41 | for (var i = 0; i < n; i++) { 42 | points.push( 43 | { ts: Date.now() - n * 1000 44 | , count: 100 * Math.random() 45 | }) 46 | } 47 | return points 48 | } 49 | 50 | function makeHours() { 51 | var hours = {} 52 | hours[(new Date).getHours()] = {} 53 | return hours 54 | } 55 | 56 | function RuleDB(db) { 57 | this.db = db 58 | } 59 | 60 | RuleDB.prototype.getRule = function(mkey, callback) { 61 | var db = this.db 62 | process.nextTick(function() { 63 | callback(null, db.rules) 64 | }) 65 | } 66 | 67 | RuleDB.prototype.getPoints = function(mkey, start, end, callback) { 68 | var db = this.db 69 | if (!db.points) throw new Error 70 | process.nextTick(function() { 71 | callback(null, db.points) 72 | }) 73 | } 74 | 75 | RuleDB.prototype.setRule = function(mkey, rule, callback) { 76 | this.db.rules = rule 77 | process.nextTick(callback) 78 | } 79 | -------------------------------------------------------------------------------- /daemon/test/monitor/rule-tester.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , Rule = require('../../lib/monitor/rule') 3 | , RuleTester = require('../../lib/monitor/rule-tester') 4 | 5 | test("RuleTester", function(t) { 6 | var rule = makeRule() 7 | , tester = new RuleTester("foo", rule) 8 | t.equals(tester.mkey, "foo") 9 | t.equals(tester.rule, rule) 10 | t.end() 11 | }) 12 | 13 | ;[{name: "counter, too high", point: {count: 31}, warns: [{field: "count", cmp: ">"}]} 14 | , {name: "counter, too low", point: {count: 9}, warns: [{field: "count", cmp: "<"}]} 15 | , {name: "counter, ok", point: {count: 30}, warns: []} 16 | , {name: "histogram, mean too high", point: {count: 20, mean: 56}, warns: [{field: "mean", cmp: ">"}]} 17 | , {name: "histogram, mean too low", point: {count: 20, mean: 44}, warns: [{field: "mean", cmp: "<"}]} 18 | , {name: "histogram, ok", point: {count: 22, mean: 52}, warns: []} 19 | , {name: "histogram, both warns", point: {count: 0, mean: 0}, warns: [{field: "count", cmp: "<"}, {field: "mean", cmp: "<"}]} 20 | ].forEach(function(T) { 21 | test("RuleTester#test " + T.name, function(t) { 22 | var hours = {} 23 | , hour = (new Date).getHours() 24 | hours[hour] = 25 | { count: 20, count_var: 10 26 | , mean: 50, mean_var: 5 27 | } 28 | 29 | var rule = makeRule({fields: Object.keys(T.point), hours: hours}) 30 | , tester = new RuleTester("foo", rule) 31 | , warns = tester.test(T.point) 32 | 33 | t.equals(warns.length, T.warns.length) 34 | for (var i = 0; i < warns.length; i++) { 35 | t.equals(warns[i].mkey, "foo") 36 | t.equals(warns[i].field, T.warns[i].field) 37 | t.equals(warns[i].cmp, T.warns[i].cmp) 38 | } 39 | t.end() 40 | }) 41 | }) 42 | 43 | function makeRule(opts) { 44 | opts = opts || {} 45 | return new Rule( 46 | { ts: opts.ts || Date.now() 47 | , fields: opts.fields || ["count"] 48 | , hours: opts.hours || {} 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /daemon/test/monitor/rule.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , Rule = require('../../lib/monitor/rule') 3 | 4 | test("Rule", function(t) { 5 | var rule = new Rule( 6 | { ts: 123 7 | , fields: ["a", "b"] 8 | , hours: {2: {}} 9 | }) 10 | t.equals(rule.ts, 123) 11 | t.deepEquals(rule.fields, ["a", "b"]) 12 | t.deepEquals(rule.hours, {2: {}}) 13 | t.end() 14 | }) 15 | 16 | test("Rule#isExpired", function(t) { 17 | var rule1 = new Rule({ts: Date.now(), fields: ["mean"], hours: {}}) 18 | t.equals(rule1.isExpired(), true) 19 | 20 | var rule2 = new Rule({ts: Date.now(), fields: [], hours: {}}) 21 | t.equals(rule2.isExpired(), false) 22 | 23 | var hours = {} 24 | hours[(new Date).getHours()] = {} 25 | var rule3 = new Rule( 26 | { ts: Date.now() - 2*Rule.interval 27 | , fields: ["mean"] 28 | , hours: hours 29 | }) 30 | t.equals(rule3.isExpired(), true) 31 | 32 | var rule4 = new Rule({ts: Date.now(), fields: ["mean"], hours: hours}) 33 | t.equals(rule4.isExpired(), false) 34 | t.end() 35 | }) 36 | -------------------------------------------------------------------------------- /daemon/test/monitor/running-mean.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , RunningMean = require('../../lib/monitor/running-mean') 3 | 4 | test("RunningMean", function(t) { 5 | var m = new RunningMean() 6 | t.equals(m.mean, 0) 7 | m.push(0) 8 | t.equals(m.mean, 0) 9 | m.push(10) 10 | t.equals(m.mean, 5) 11 | m.push(20) 12 | t.equals(m.mean, 10) 13 | t.end() 14 | }) 15 | -------------------------------------------------------------------------------- /daemon/test/server/http.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , HTTPServer = require('../../lib/server/http') 3 | , MockRing = require('./ring.mock') 4 | , HOST = "127.0.0.1" 5 | , PORT = 8250 6 | 7 | test("HTTPServer", function(t) { 8 | var ring = new MockRing() 9 | , hs = new HTTPServer(ring) 10 | t.equals(hs.ring, ring) 11 | t.end() 12 | }) 13 | -------------------------------------------------------------------------------- /daemon/test/server/metrics-stream.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , MetricsStream = require('../../lib/server/metrics-stream') 3 | , parse = MetricsStream.parse 4 | 5 | function noop() {} 6 | 7 | test("MetricsStream", function(t) { 8 | var ms = new MetricsStream(noop) 9 | t.equals(ms.onMetrics, noop) 10 | t.equals(ms.buffer, "") 11 | t.equals(ms.writable, true) 12 | t.end() 13 | }) 14 | 15 | test("MetricsStream#write, MetricsStream#end", function(t) { 16 | var metrics = [] 17 | , ms = new MetricsStream(metrics.push.bind(metrics)) 18 | , set1 = 19 | [ {type: "counter", key: "foo", value: 2} 20 | , {type: "histogram", key: "bar", value: -12} 21 | ] 22 | ms.write("counter:foo=2") 23 | t.deepEquals(metrics, []) 24 | ms.write("\nhistogram:bar=-12\n\n") 25 | t.deepEquals(metrics, [set1]) 26 | ms.write("counter:baz=5.2") 27 | t.deepEquals(metrics, [set1]) 28 | ms.end() 29 | t.deepEquals(metrics, [set1, [{type: "counter", key: "baz", value: 5.2}]]) 30 | t.equals(ms.buffer, "") 31 | t.end() 32 | }) 33 | 34 | test("MetricsStream.parse", function(t) { 35 | var invalid = ["", "a", "a=1", "a:b", "a:1", "counter:foo=A"] 36 | for (var i = 0; i < invalid.length; i++) { 37 | t.deepEquals(parse(invalid[i]), []) 38 | } 39 | 40 | t.deepEquals(parse("counter:a=1"), [{type: "counter", key: "a", value: 1}]) 41 | t.deepEquals(parse("counter:a=1.2"), [{type: "counter", key: "a", value: 1.2}]) 42 | t.deepEquals(parse("counter:a=-1.2"), [{type: "counter", key: "a", value: -1.2}]) 43 | 44 | t.deepEquals(parse("counter:foo=1\nhistogram:bar=2"), 45 | [ {type: "counter", key: "foo", value: 1} 46 | , {type: "histogram", key: "bar", value: 2} 47 | ]) 48 | t.end() 49 | }) 50 | -------------------------------------------------------------------------------- /daemon/test/server/ring.mock.js: -------------------------------------------------------------------------------- 1 | module.exports = MockRing 2 | 3 | function MockRing() { this.ops = [] } 4 | 5 | MockRing.prototype.metrics = function(metrics, isLocal) { 6 | this.ops.push(["metrics", metrics, isLocal]) 7 | } 8 | -------------------------------------------------------------------------------- /daemon/test/server/udp.test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test 2 | , UDPServer = require('../../lib/server/udp') 3 | , MockRing = require('./ring.mock') 4 | 5 | test("UDPServer", function(t) { 6 | var ring = new MockRing() 7 | , udp = new UDPServer(ring) 8 | t.equals(udp.ring, ring) 9 | t.end() 10 | }) 11 | 12 | test("UDPServer#onMessage", function(t) { 13 | test("bulk metrics", function(t) { 14 | var ring = new MockRing() 15 | , udp = new UDPServer(ring) 16 | 17 | udp.onMessage(new Buffer 18 | ( "counter:foo=7\n" 19 | + "counter:bar=-2\n" 20 | + "histogram:baz=5.2"), {}) 21 | t.deepEquals(ring.ops, [ 22 | [ "metrics" 23 | , [ {type: "counter", key: "foo", value: 7} 24 | , {type: "counter", key: "bar", value: -2} 25 | , {type: "histogram", key: "baz", value: 5.2} 26 | ] 27 | , false 28 | ]]) 29 | t.end() 30 | }) 31 | 32 | test("invalid datagrams", function(t) { 33 | var ring = new MockRing() 34 | , udp = new UDPServer(ring) 35 | 36 | send("") 37 | send("abc") 38 | t.deepEquals(ring.ops, []) 39 | t.end() 40 | 41 | function send(str) { 42 | udp.onMessage(new Buffer(str), 0, str.length) 43 | } 44 | }) 45 | 46 | test("invalid metrics", function(t) { 47 | var ring = new MockRing() 48 | , udp = new UDPServer(ring) 49 | 50 | udp.onMessage(new Buffer 51 | ( "invalid:foo=7\n" // invalid type 52 | + "counter:a?b=8\n" // invalid key 53 | + "counter:bar=1.2\n" // valid 54 | + "counter:foo=12b\n")) // invalid value 55 | 56 | t.deepEquals(ring.ops, [ 57 | [ "metrics", 58 | [ {type: "invalid", key: "foo", value: 7} 59 | , {type: "counter", key: "a?b", value: 8} 60 | , {type: "counter", key: "bar", value: 1.2} 61 | ] 62 | , false] 63 | ]) 64 | t.end() 65 | }) 66 | 67 | t.end() 68 | }) 69 | -------------------------------------------------------------------------------- /web/app/metrics/downsample/counter.js: -------------------------------------------------------------------------------- 1 | var sample = require('./sample') 2 | 3 | /// Downsample a counter by summing the intervals. 4 | /// 5 | /// data - [{ts, count}] 6 | /// delta - Integer (milliseconds) 7 | /// 8 | /// Returns [{ts, count}] 9 | module.exports = function(data, delta) { 10 | return sample(data, delta, initPoint, sumPoint); 11 | } 12 | 13 | function initPoint(ts) { 14 | return { ts: ts 15 | , count: 0 16 | } 17 | } 18 | 19 | function sumPoint(ptA, ptB) { ptA.count += ptB.count } 20 | -------------------------------------------------------------------------------- /web/app/metrics/downsample/histogram.js: -------------------------------------------------------------------------------- 1 | var sample = require('./sample') 2 | , KEYS = ["mean", "median", "std_dev", "p10", "p75", "p95", "p99", "max"] 3 | , KEY_COUNT = KEYS.length 4 | 5 | /// Downsample a counter by summing the intervals. 6 | /// 7 | /// data - [{ts, count}] 8 | /// delta - Integer (milliseconds) 9 | /// 10 | /// Returns [{ts, count}] 11 | var H = 12 | module.exports = function(data, delta) { 13 | return sample(data, delta, initPoint, combine, average); 14 | } 15 | 16 | H.KEYS = KEYS 17 | 18 | function initPoint(ts) { 19 | var pt = {ts: ts, count: 0} 20 | for (var i = 0; i < KEY_COUNT; i++) { 21 | pt[KEYS[i]] = 0 22 | } 23 | return pt 24 | } 25 | 26 | function combine(ptA, ptB) { 27 | ptA.count += ptB.count 28 | for (var i = 0; i < KEY_COUNT; i++) { 29 | var key = KEYS[i] 30 | if (key === "max") { 31 | ptA[key] = Math.max(ptA[key], ptB[key]) 32 | } else { 33 | ptA[key] += ptB[key] 34 | } 35 | } 36 | return ptA 37 | } 38 | 39 | function average(point, count) { 40 | for (var i = 0; i < KEY_COUNT; i++) { 41 | var key = KEYS[i] 42 | if (key === "max") continue 43 | point[key] /= count 44 | } 45 | return point 46 | } 47 | -------------------------------------------------------------------------------- /web/app/metrics/downsample/llquantize.js: -------------------------------------------------------------------------------- 1 | var sample = require('./sample') 2 | 3 | /// Downsample llquantize data by merging the buckets. 4 | /// 5 | /// data - [{ts, data}] 6 | /// delta - Integer (milliseconds) 7 | /// 8 | /// Returns [{ts, count}] 9 | module.exports = function(data, delta) { 10 | return sample(data, delta, initPoint, sumPoint); 11 | } 12 | 13 | function initPoint(ts) { 14 | return { ts: ts 15 | , data: {} 16 | } 17 | } 18 | 19 | // ptA - {ts, data} 20 | // ptB - {ts, data} 21 | function sumPoint(ptA, ptB) { 22 | var dataA = ptA.data 23 | , dataB = ptB.data 24 | , newBuckets = Object.keys(dataB) 25 | for (var i = 0, l = newBuckets.length; i < l; i++) { 26 | var bucket = newBuckets[i] 27 | dataA[bucket] = (dataA[bucket] || 0) + dataB[bucket] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /web/app/metrics/downsample/sample.js: -------------------------------------------------------------------------------- 1 | /// Downsample an array of points. 2 | /// 3 | /// points - [Point] 4 | /// delta - Integer, milliseconds 5 | /// combine(pointA, pointB)->point 6 | /// post(point, count)->point 7 | /// 8 | /// Returns [Point] 9 | module.exports = function(points, delta, init, combine, post) { 10 | if (!points.length) return [] 11 | 12 | var newPoints = [] 13 | , currentCount = 0 14 | , current, currentBegin, point 15 | for (var i = 0; i < points.length; i++) { 16 | point = points[i] 17 | if (i === 0 || point.ts - currentBegin >= delta) { 18 | if (i > 0) pushPoint(current) 19 | currentBegin = point.ts - point.ts % delta 20 | current = init(currentBegin) 21 | currentCount = 0 22 | } 23 | if (!point.empty) { 24 | safeCombine(current, point) 25 | currentCount++ 26 | } 27 | } 28 | pushPoint(current) 29 | return newPoints 30 | 31 | function pushPoint(pt) { 32 | newPoints.push( currentCount === 0 ? {ts: currentBegin, empty: true} 33 | : post ? post(pt, currentCount) 34 | : pt ) 35 | } 36 | 37 | function safeCombine(ptA, ptB) { 38 | return ptA.empty ? ptB 39 | : ptB.empty ? ptA 40 | : combine(ptA, ptB) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /web/app/metrics/flatten-llq.js: -------------------------------------------------------------------------------- 1 | var sort = require('../../lib/sort') 2 | 3 | /// Flatten the array of objects into an array of arrays. 4 | /// The returned structure is sent to the callback, but is not cached. 5 | /// 6 | /// points - [{ts, data} or {ts, empty}] 7 | /// 8 | /// Returns [[ts, bucketA, freqA, bucketB, freqB ...]] 9 | module.exports = function(points) { 10 | var newPoints = [] 11 | for (var i = 0, pl = points.length; i < pl; i++) { 12 | newPoints.push(flattenPoint(points[i])) 13 | } 14 | return newPoints 15 | } 16 | 17 | // point - {ts, data} or {ts, empty} 18 | // Returns [ts, bucketA, freqA, bucketB, freqB ...] 19 | function flattenPoint(point) { 20 | var column = [point.ts] 21 | if (point.empty) return column 22 | 23 | var buckets = sort(toNums(Object.keys(point.data))) 24 | for (var b = 0, bl = buckets.length; b < bl; b++) { 25 | var bucket = buckets[b] 26 | , freq = point.data[bucket] 27 | column.push(+bucket) 28 | column.push(freq) 29 | } 30 | return column 31 | } 32 | 33 | function toNums(ary) { 34 | for (var i = 0, l = ary.length; i < l; i++) ary[i] = +ary[i] 35 | return ary 36 | } 37 | -------------------------------------------------------------------------------- /web/app/models/channel/channel.js: -------------------------------------------------------------------------------- 1 | var qs = require('querystring') 2 | , LiveRequest = require('./request') 3 | , flattenLLQ = require('../../metrics/flatten-llq') 4 | , isLLQ = /@llq$/ 5 | 6 | module.exports = MetricsChannel 7 | 8 | /// pool - Pool of metrics-daemons. 9 | /// delta - Integer 10 | /// es - EventSource 11 | function MetricsChannel(pool, delta, es) { 12 | this.pool = pool 13 | this.delta = delta 14 | this.es = es // EventSource 15 | this.requests = {} // { mkey : LiveRequest } 16 | this._touch = Date.now() 17 | this._onPoint = this.onPoint.bind(this) 18 | 19 | // Let the client know that it is OK to send their key list. 20 | this.es.emit("init", {}) 21 | } 22 | 23 | // Clean up all pending requests. 24 | MetricsChannel.prototype.destroy = function() { 25 | var requests = this.requests 26 | , mkeys = Object.keys(requests) 27 | for (var i = 0; i < mkeys.length; i++) { 28 | this.remove(mkeys[i]) 29 | } 30 | this.es.end() 31 | this.pool = this.requests = this.es = this._onPoint = null 32 | } 33 | 34 | // Bump the time till expiration. 35 | MetricsChannel.prototype.touch = function() { this._touch = Date.now() } 36 | 37 | // Returns Boolean: true if the channel has expired. 38 | MetricsChannel.prototype.isExpired = function() { 39 | return Date.now() - this._touch > 20 * 60 * 1000 40 | } 41 | 42 | // Poll a metrics daemon for a live stream of points on the given metrics key. 43 | // 44 | // mkey - String 45 | // 46 | MetricsChannel.prototype.add = function(mkey) { 47 | this.touch() 48 | if (this.requests[mkey]) return 49 | var host = this.pool.get_endpoint().name 50 | , url = "http://" + host + "/api/live/" + encodeURIComponent(mkey) 51 | + "?" + qs.stringify({delta: this.delta}) 52 | this.requests[mkey] = new LiveRequest(url, this._onPoint) 53 | } 54 | 55 | // Stop listening to the given key. 56 | // 57 | // mkey - String 58 | // 59 | MetricsChannel.prototype.remove = function(mkey) { 60 | if (this.requests[mkey]) { 61 | this.touch() 62 | this.requests[mkey].destroy() 63 | delete this.requests[mkey] 64 | } 65 | } 66 | 67 | MetricsChannel.prototype.onPoint = function(point) { 68 | if (isLLQ.test(point.key)) { 69 | point.data = flattenLLQ([point])[0] 70 | } 71 | this.es.emit("point", point) 72 | } 73 | -------------------------------------------------------------------------------- /web/app/models/channel/index.js: -------------------------------------------------------------------------------- 1 | var MetricsChannel = require('./channel') 2 | 3 | module.exports = MetricsChannels 4 | 5 | function MetricsChannels(daemonPool) { 6 | this.pool = daemonPool 7 | this.channels = {} // { channelID : MetricsChannel } 8 | this.interval = setInterval(this.clean.bind(this), 60000) 9 | } 10 | 11 | MetricsChannels.prototype.close = function() { 12 | clearInterval(this.interval) 13 | } 14 | 15 | // Clean up expired channels. 16 | MetricsChannels.prototype.clean = function() { 17 | var channels = this.channels 18 | , channelIDs = Object.keys(channels) 19 | for (var i = 0; i < channels; i++) { 20 | var channelID = channels[i] 21 | if (channels[channelID].isExpired()) { 22 | this.destroy(channelID) 23 | } 24 | } 25 | } 26 | 27 | // channelID - String 28 | // delta - Integer, milliseconds 29 | // es - EventSource 30 | MetricsChannels.prototype.create = function(channelID, delta, es) { 31 | return this.channels[channelID] = new MetricsChannel(this.pool, delta, es) 32 | } 33 | 34 | // channelID - String 35 | // mkey - String 36 | MetricsChannels.prototype.add = function(channelID, mkey) { 37 | var channel = this.channels[channelID] 38 | if (channel) return channel.add(mkey) 39 | } 40 | 41 | // channelID - String 42 | // mkey - String 43 | MetricsChannels.prototype.remove = function(channelID, mkey) { 44 | var channel = this.channels[channelID] 45 | if (channel) return channel.remove(mkey) 46 | } 47 | 48 | // channelID - String 49 | MetricsChannels.prototype.destroy = function(channelID) { 50 | var channel = this.channels[channelID] 51 | if (channel) { 52 | channel.destroy() 53 | delete this.channels[channelID] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /web/app/models/channel/request.js: -------------------------------------------------------------------------------- 1 | var http = require('http') 2 | 3 | module.exports = LiveRequest 4 | 5 | function LiveRequest(url, onPoint) { 6 | this.onPoint = onPoint 7 | this.request = http.get(url, this.onResponse.bind(this)) 8 | .on("error", this.onError.bind(this)) 9 | this.buffer = "" 10 | } 11 | 12 | LiveRequest.prototype.destroy = function() { 13 | if (this.request) this.request.destroy() 14 | this.onPoint = this.request = this.buffer = null 15 | } 16 | 17 | LiveRequest.prototype.onError = LiveRequest.prototype.destroy 18 | 19 | LiveRequest.prototype.onResponse = function(res) { 20 | res.on("data", this.onData.bind(this)) 21 | .on("end", this.onEnd.bind(this)) 22 | } 23 | 24 | LiveRequest.prototype.onData = function(buf) { 25 | this.buffer += buf.toString() 26 | this.flush() 27 | } 28 | 29 | LiveRequest.prototype.onEnd = function() { 30 | this.buffer += "\n" 31 | this.flush() 32 | } 33 | 34 | LiveRequest.prototype.flush = function() { 35 | if (!this.onPoint) return 36 | 37 | var chunks = this.buffer.split("\n") 38 | for (var i = 0, l = chunks.length - 1; i < l; i++) { 39 | var pt = parse(chunks[i]) 40 | if (pt) this.onPoint(pt) 41 | } 42 | this.buffer = chunks[l] 43 | } 44 | 45 | function parse(str) { 46 | try { 47 | return JSON.parse(str) 48 | } catch (e) {} 49 | } 50 | -------------------------------------------------------------------------------- /web/app/models/dashboard.js: -------------------------------------------------------------------------------- 1 | module.exports = DashboardManager 2 | 3 | /// 4 | /// A dashboard looks like: 5 | /// 6 | /// { id: String 7 | /// , graphs: 8 | /// { : 9 | /// { id: String (its an Integer) 10 | /// , title: String 11 | /// , keys: ["metrics_foo"] 12 | /// , renderer: String 13 | /// , subkey: String 14 | /// , histkeys: [String] 15 | /// } 16 | /// // ... 17 | /// } 18 | /// } 19 | /// 20 | function DashboardManager(db) { 21 | this.db = db 22 | this.cache = null // [String dashboard ID] 23 | } 24 | 25 | DashboardManager.prototype.get = function(id, callback) { this.db.getDashboard(id, callback) } 26 | 27 | // Set the value of a dashboard. 28 | DashboardManager.prototype.set = function(id, val, callback) { 29 | if (this.cache && this.cache.indexOf(id) === -1) { 30 | this.cache.push(id) 31 | } 32 | val.id = id 33 | this.db.setDashboard(id, val, callback) 34 | } 35 | 36 | DashboardManager.prototype.del = function(id, callback) { 37 | if (this.cache) { 38 | var index = this.cache.indexOf(id) 39 | if (index !== -1) this.cache.splice(index, 1) 40 | } 41 | this.db.deleteDashboard(id, callback) 42 | } 43 | 44 | // Add a graph to the end of the dashboard. 45 | // 46 | // id - String 47 | // updates - {id, graphs} 48 | // callback - Receives `(err)`. 49 | // 50 | DashboardManager.prototype.modify = function(id, updates, callback) { 51 | var _this = this 52 | this.get(id, function(err, dash) { 53 | if (err) return callback(err) 54 | if (!dash) return callback(new Error("no dashboard")) 55 | 56 | dash.id = updates.id || id 57 | _this.set(dash.id, applyUpdates(dash, updates), function(err) { 58 | if (err) return callback(err) 59 | if (updates.id && updates.id !== id) { 60 | _this.del(id, callback) 61 | } else { 62 | callback() 63 | } 64 | }) 65 | }) 66 | } 67 | 68 | function applyUpdates(dashboard, updates) { 69 | var graphs = updates.graphs || {} 70 | , graphIDs = Object.keys(graphs) 71 | for (var i = 0; i < graphIDs.length; i++) { 72 | var graphID = graphIDs[i] 73 | , graph = graphs[graphID] 74 | if (graph) { 75 | dashboard.graphs[graphID] = graph 76 | } else { 77 | delete dashboard.graphs[graphID] 78 | } 79 | } 80 | return dashboard 81 | } 82 | 83 | 84 | // callback - Receives `(err, ids)`. 85 | DashboardManager.prototype.list = function(callback) { 86 | if (this.cache) return callback(null, this.cache) 87 | var _this = this 88 | this.db.listDashboards(function(err, json) { 89 | callback(err, (_this.cache = json) || []) 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /web/app/models/monitor.js: -------------------------------------------------------------------------------- 1 | module.exports = Monitor 2 | 3 | /// Access the metrics-daemon monitoring API. 4 | /// 5 | /// pool - Pool of all metrics-daemons. 6 | /// 7 | function Monitor(pool) { 8 | this.pool = pool 9 | } 10 | 11 | Monitor.prototype.getAllWarnings = function(callback) { 12 | var endpoints = this.pool.endpoints 13 | , warnings = [] 14 | , endpointsL = endpoints.length 15 | , total = endpointsL 16 | , errs = 0 17 | for (var i = 0; i < endpointsL; i++) { 18 | this.pool.get( 19 | { endpoint: endpoints[i].name 20 | , path: "/api/monitor" 21 | }, onResponse) 22 | } 23 | 24 | function onResponse(err, res, body) { 25 | if (err) errs++ 26 | if (res && res.statusCode === 200) { 27 | warnings = warnings.concat(JSON.parse(body)) 28 | } 29 | if (--total === 0) { 30 | if (errs === endpointsL) return callback(err) 31 | callback(null, warnings) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /web/app/models/tag-type.js: -------------------------------------------------------------------------------- 1 | module.exports = TagTypeManager 2 | 3 | var reColor = /^#([0-9a-f]{3}){1,2}$/i 4 | 5 | /// A rough wrapper around `db` that caches the list of tag types. 6 | /// 7 | /// A tag type is: 8 | /// * id - String 9 | /// * color - String, e.g. "#ff0000" 10 | /// * name - String, a short description of the tag. 11 | /// 12 | /// There shouldn't need to be more than a dozen or so tag types, and they 13 | /// are only rarely modified. 14 | /// 15 | /// db - Backend 16 | /// 17 | function TagTypeManager(db) { 18 | this.db = db 19 | this.cache = null 20 | } 21 | 22 | // Returns Boolean 23 | TagTypeManager.isColor = function(str) { 24 | return str && reColor.test(str) 25 | } 26 | 27 | // callback(err, [{id, name, color}]) 28 | TagTypeManager.prototype.getTagTypes = function(callback) { 29 | if (this.cache) return callback(null, this.cache) 30 | var _this = this 31 | this.db.getTagTypes(function(err, tagtypes) { 32 | if (err) return callback(err) 33 | callback(null, _this.cache = tagtypes) 34 | }) 35 | } 36 | 37 | // opts - {color, name} 38 | // callback(err) 39 | TagTypeManager.prototype.createTagType = function(opts, callback) { 40 | this.cache = null 41 | this.db.createTagType(opts, callback) 42 | } 43 | 44 | // typeID - String 45 | // callback(err) 46 | TagTypeManager.prototype.deleteTagType = function(typeID, callback) { 47 | this.cache = null 48 | this.db.deleteTagType(typeID, callback) 49 | } 50 | -------------------------------------------------------------------------------- /web/bin/admin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var path = require('path') 3 | , APIClient = require('../client/api') 4 | , admin = require('../client/admin') 5 | , request = require('../lib/request') 6 | var argv = process.argv 7 | , runner = argv[0] + " " + path.basename(argv[1]) 8 | , args = argv.slice(2) 9 | , command = args[0] 10 | , subcom = args[1] 11 | , host = process.env.METRICS_HOST 12 | , api = host && new APIClient(request(host)) 13 | , l = console.error 14 | 15 | if (!command || !host) usage() 16 | 17 | var AdminClient = admin[command] 18 | if (!AdminClient) usage() 19 | 20 | var adminClient = new AdminClient(api) 21 | if (args.length === 1 22 | || subcom === "help" 23 | || !adminClient[subcom]) subUsage() 24 | 25 | var error = adminClient[subcom](args.slice(2)) 26 | if (error) subUsage() 27 | 28 | //////////////////////////////////////////////////////////////////////////////// 29 | 30 | function usage() { 31 | l("Usage: " + runner + " [arguments]") 32 | l("") 33 | l("Commands:") 34 | l(" dashboard") 35 | l(" keys") 36 | l(" monitor") 37 | l(" tag") 38 | l(" tagtype") 39 | l("") 40 | l("$METRICS_HOST=" + (host || "?")) 41 | l("") 42 | l("Documentation can be found at https://github.com/Voxer/zag") 43 | process.exit(1) 44 | } 45 | 46 | function subUsage() { 47 | l("Usage: " + runner + " " + command + " [arguments]") 48 | l("") 49 | l("Commands:") 50 | l(adminClient.help.map(prefix(" " + command + " ")).join("\n")) 51 | process.exit(1) 52 | } 53 | 54 | function prefix(pref) { 55 | return function(str) { return pref + str } 56 | } 57 | -------------------------------------------------------------------------------- /web/client/admin/dashboard.js: -------------------------------------------------------------------------------- 1 | var stdin = require('./util').stdin 2 | 3 | module.exports = DashboardAdmin 4 | 5 | function DashboardAdmin(api) { this.api = api } 6 | 7 | DashboardAdmin.prototype.help = 8 | [ "list" 9 | , "create Create an empty dashboad." 10 | , "get Print the dashboard JSON." 11 | , "rename " 12 | , "update Receives dashboard JSON via stdin." 13 | , "delete " 14 | ] 15 | 16 | DashboardAdmin.prototype.list = function() { 17 | this.api.listDashboards(function(err, ids) { 18 | if (err) throw err 19 | ids.sort() 20 | for (var i = 0; i < ids.length; i++) { 21 | console.log(ids[i]) 22 | } 23 | }) 24 | } 25 | 26 | // TODO the id needs to be validated with the same regex as the UI. 27 | // args - [String name] 28 | DashboardAdmin.prototype.create = function(args) { 29 | var id = args[0] 30 | if (!id) return 1 31 | 32 | this.api.replaceDashboard(id, {graphs: {}}, done) 33 | } 34 | 35 | // args - [String id] 36 | DashboardAdmin.prototype.get = function(args) { 37 | var id = args[0] 38 | if (!id) return 1 39 | 40 | this.api.getDashboard(id, function(err, dashboard) { 41 | if (err) throw err 42 | if (dashboard) { 43 | console.log(JSON.stringify(dashboard, null, " ")) 44 | } else { 45 | console.log("not found") 46 | } 47 | }) 48 | } 49 | 50 | DashboardAdmin.prototype.show = DashboardAdmin.prototype.get 51 | 52 | // args - [String fromID, String toID] 53 | DashboardAdmin.prototype.rename = function(args) { 54 | var fromID = args[0] 55 | , toID = args[1] 56 | if (!fromID || !toID) return 1 57 | 58 | this.api.patchDashboard(fromID, {id: toID}, done) 59 | } 60 | 61 | // args - [String id] 62 | DashboardAdmin.prototype.update = function(args) { 63 | var api = this.api 64 | , id = args[0] 65 | if (!id) return 1 66 | 67 | stdin(function(err, body) { 68 | if (err) throw err 69 | api.patchDashboard(id, JSON.parse(body), done) 70 | }) 71 | } 72 | 73 | // args - [String id] 74 | DashboardAdmin.prototype.delete = function(args) { 75 | var id = args[0] 76 | if (!id) return 1 77 | 78 | this.api.deleteDashboard(id, done) 79 | } 80 | 81 | function done(err) { if (err) throw err } 82 | -------------------------------------------------------------------------------- /web/client/admin/index.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | { dashboard: require('./dashboard') 3 | , keys: require('./keys') 4 | , monitor: require('./monitor') 5 | , tag: require('./tag') 6 | , tagtype: require('./tag-type') 7 | } 8 | -------------------------------------------------------------------------------- /web/client/admin/keys.js: -------------------------------------------------------------------------------- 1 | var stdin = require('./util').stdin 2 | 3 | // Bulk key deletion max concurrency. 4 | var DELETE_CC = 50 5 | // Default key filter limit. 6 | , FILTER_LIMIT = 1000 7 | 8 | module.exports = KeysAdmin 9 | 10 | function KeysAdmin(api) { this.api = api } 11 | 12 | KeysAdmin.prototype.help = 13 | [ "all List ALL keys. This might take a while." 14 | , "list [parent] When parent is not provided, list root keys." 15 | , "filter [max]" 16 | , "delete Recursive. Can bulk-delete a \\n-delimited keys via STDIN" 17 | ] 18 | 19 | KeysAdmin.prototype.all = function() { 20 | this.api.getAllKeys(function(err, keys) { 21 | if (err) throw err 22 | for (var i = 0; i < keys.length; i++) { 23 | console.log(keys[i]) 24 | } 25 | }) 26 | } 27 | 28 | // args - [String parent?] 29 | KeysAdmin.prototype.list = function(args) { 30 | var parent = args[0] 31 | if (!parent) { 32 | this.api.getRootKeys(printKeysCallback) 33 | return 34 | } 35 | this.api.getChildKeys(parent, function(err, mtree) { 36 | if (err) throw err 37 | var mkeys = mtree[parent] 38 | if (mkeys) printKeys(mkeys) 39 | }) 40 | } 41 | 42 | KeysAdmin.prototype.ls = KeysAdmin.prototype.list 43 | 44 | // args - [String pattern, Integer limit?] 45 | KeysAdmin.prototype.filter = function(args) { 46 | var pattern = args[0] 47 | , limit = args[1] ? +args[1] : FILTER_LIMIT 48 | if (!pattern || isNaN(limit)) return 1 49 | 50 | this.api.filterKeys(pattern, limit, printKeysCallback) 51 | } 52 | 53 | // args - [String mkey] 54 | KeysAdmin.prototype.delete = function(args) { 55 | var mkey = args[0] 56 | , _this = this 57 | if (mkey) this.api.deleteKey(mkey, done) 58 | else { 59 | stdin(function(err, data) { 60 | if (err) throw err 61 | _this.deleteMany(data.toString().trim().split("\n")) 62 | }) 63 | } 64 | } 65 | 66 | // Internal: Bulk delete. 67 | // 68 | // This isn't terribly kind to the server. 69 | KeysAdmin.prototype.deleteMany = function(mkeys) { 70 | var cc = Math.min(DELETE_CC, mkeys.length) 71 | , api = this.api 72 | for (var i = 0; i < cc; i++) { 73 | var mkey = mkeys[i] 74 | if (mkey) api.deleteKey(mkey, next) 75 | } 76 | 77 | function next(err) { 78 | if (err) throw err 79 | var mkey = mkeys[i++] 80 | if (mkey === undefined) return 81 | api.deleteKey(mkey, next) 82 | } 83 | } 84 | 85 | function done(err) { 86 | if (err) throw err 87 | } 88 | 89 | function printKeysCallback(err, mkeys) { 90 | if (err) throw err 91 | printKeys(mkeys) 92 | } 93 | 94 | function printKeys(mkeys) { 95 | for (var i = 0; i < mkeys.length; i++) { 96 | console.log(mkeys[i].key) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /web/client/admin/monitor.js: -------------------------------------------------------------------------------- 1 | var table = require('./util').table 2 | 3 | var HEADER = ["", "key", "value", "", "expected", "", "margin", "std_dev"] 4 | , FIELDS = {count: "C", mean: "M"} 5 | 6 | module.exports = MonitorAdmin 7 | 8 | function MonitorAdmin(api) { this.api = api } 9 | 10 | MonitorAdmin.prototype.help = 11 | [ "json Print warnings as JSON." 12 | , "table Print a table of alerts every minute." 13 | ] 14 | 15 | MonitorAdmin.prototype.json = function() { 16 | this.api.monitor(function(err, warnings) { 17 | if (err) throw err 18 | console.log(JSON.stringify(warnings, null, " ")) 19 | }) 20 | } 21 | 22 | MonitorAdmin.prototype.table = function() { 23 | var api = this.api 24 | 25 | ;(function getWarnings() { 26 | api.monitor(onWarnings) 27 | setTimeout(getWarnings, 60000) 28 | })() 29 | 30 | function onWarnings(err, warnings) { 31 | if (err) throw err 32 | var rows = table([HEADER].concat(warnings.sort(byMKey).map(warningToRow))); 33 | console.log() 34 | for (var i = 0; i < rows.length; i++) { 35 | console.log(rows[i]) 36 | } 37 | } 38 | } 39 | 40 | function warningToRow(warn) { 41 | return [ FIELDS[warn.field] 42 | , warn.mkey 43 | , warn.value.toFixed(1) 44 | , warn.cmp 45 | , warn.target.toFixed(1) 46 | , "+" 47 | , warn.margin.toFixed(1) 48 | , Math.sqrt(warn.margin).toFixed(1) 49 | ] 50 | } 51 | 52 | function byMKey(warnA, warnB) { 53 | var ak = warnA.mkey 54 | , bk = warnB.mkey 55 | return ak < bk ? -1 56 | : ak > bk ? 1 57 | : 0 58 | } 59 | -------------------------------------------------------------------------------- /web/client/admin/tag-type.js: -------------------------------------------------------------------------------- 1 | module.exports = TagTypeAdmin 2 | 3 | function TagTypeAdmin(api) { this.api = api } 4 | 5 | TagTypeAdmin.prototype.help = 6 | [ "list" 7 | , "create <#color> " 8 | , "delete " 9 | ] 10 | 11 | TagTypeAdmin.prototype.list = function() { 12 | this.api.getTagTypes(function(err, tagtypes) { 13 | if (err) throw err 14 | for (var i = 0; i < tagtypes.length; i++) { 15 | var tt = tagtypes[i] 16 | console.log(tt.id + "\t" + tt.color + "\t'" + tt.name + "'") 17 | } 18 | }) 19 | } 20 | 21 | // args - [String color, String name] 22 | TagTypeAdmin.prototype.create = function(args) { 23 | var color = args[0] 24 | , name = args[1] 25 | if (!color || !name) return 1 26 | 27 | this.api.createTagType( 28 | { color: color 29 | , name: name 30 | }, done) 31 | } 32 | 33 | // args - [String tagID] 34 | TagTypeAdmin.prototype.delete = function(args) { 35 | var tagID = args[0] 36 | if (!tagID) return 1 37 | this.api.deleteTagType(tagID, done) 38 | } 39 | 40 | function done(err) { if (err) throw err } 41 | -------------------------------------------------------------------------------- /web/client/admin/tag.js: -------------------------------------------------------------------------------- 1 | module.exports = TagAdmin 2 | 3 | function TagAdmin(api) { this.api = api } 4 | 5 | TagAdmin.prototype.help = 6 | [ "list [begin [end]]" 7 | , "create <#color>