Skip to content
This repository has been archived by the owner on Oct 27, 2018. It is now read-only.

Add InfluxDB Line Protocol Support, and Add endpoint for JSON #18

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ to `/APP_ROOT/v1/send` on whichever port you specify.
```bash
# Install nodejs
# This assumes you're on a 64 bit machine
wget http://nodejs.org/dist/v0.10.19/node-v0.10.19-linux-x64.tar.gz
tar xvf node-v0.10.19-linux-x64.tar.gz
sudo ln -s `pwd`/node-v0.10.19-linux-x64/bin/{node,npm} /usr/local/bin/
wget https://nodejs.org/dist/v4.1.1/node-v4.1.1-linux-x64.tar.gz
tar xvf node-v4.1.1-linux-x64.tar.gz
sudo ln -s `pwd`/node-v4.1.1-linux-x64/bin/{node,npm} /usr/local/bin/

# Grab a Bucky release
# You should use the latest release available at https://github.com/HubSpot/BuckyServer/releases
Expand Down Expand Up @@ -101,7 +101,14 @@ If you need more customization, you can write a module:

There are a few of types of modules:


- Server - Use to set properties of the Bucky Server
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is describing a new type of module. Where is that module defined and used in the source?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm rewriting this now. I must have gotten interrupted initially when I was writing out the configuration option details and clearly mixed up the config options with modules. Thanks for catching this.

- port - Use to set the port that Bucky Server will listen to
- appRoot - Use to define the root of the endpoint
- https - Use to set options for running Bucky Server in https mode
- port - Use to specify the port for https, if not populated the http server port + 1
- options - Use to define the certificates for https. [List of all possible options](https://nodejs.org/api/tls.html#tls_tls_createserver_options_secureconnectionlistener)
- For all options that accept a buffer you can use the path to the file and they'll be read in
- httpsOnly - If this flag is set to true then Bucky Server will not run in http mode
- Logger - Use to have Bucky log to something other than the console
- Config - Use to have Bucky pull config from somewhere other than the default file
- App - Use to do things when Bucky loads and/or on requests. Auth, monitoring initialization, etc.
Expand Down Expand Up @@ -180,7 +187,7 @@ called with like this:
You are free to implement the `on` method as a dud if live reloading doesn't
make sense using your config system. Take a look at [lib/configWrapper.coffee](lib/configWrapper.coffee)
for an example of how a basic object can be converted (and feel free to use it).

#### App

App modules get loaded once, and can optionally provide a function to be ran with each request.
Expand Down Expand Up @@ -236,7 +243,10 @@ module.exports = ({app, logger, config}, next) ->

### Format

If you are interested in writing new clients, the format of metric data is the same as is used by statsd:
If you are interested in writing new clients, there are two endpoints for inbound data.
The default endpoint uses the same format as statsd:

default endpoint: `{hostname}:{port}/v1/{appRoot}` uses

```
<metric name>:<metric value>|<unit>[@<sample rate>]
Expand All @@ -249,4 +259,24 @@ my.awesome.metric:35|ms
some.other.metric:3|[email protected]
```

All requests are sent with content-type `text/plain`.
All post reqeusts sent to the default endpoint must use content-type `text/plain`.

JSON endpoint: `{hostname}:{port}/v1/{appRoot}/json` uses

```javascript
{
"<metric name>": "<metric value>[|<unit>[@<sample rate>]"
}
```

This allows for the ':' character to be included in your metrics. This is valid for InfluxDB Line Protocol.

For example:
```javascript
{
"page,browser=Chrome,browserVersion=44,url=http://localhost:3000/#customHash/%7Bexample%3A%22encoded%20data%22%7D,key=domContentLoadedEventEnd": "500|ms",
"ajax,browser=Microsoft\\ Internet\\ Explorer,browserVersion=8,url=http://localhost:3000/#customHash/%7Bexample%3A%22encoded%20data%22%7D,endpoint=your/awesome/template.html,method=GET,status=200": "1|c"
}
```

All post request to the json endpoint will be *converted* to content-type 'application/json'. This allows for backwards compatibility with IE8 which can't send XDomainRequest with a content-type other than 'plain/text'.
13 changes: 11 additions & 2 deletions config/default.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
server:
port: 5999
appRoot: "/bucky"
# If https options are set then bucky will also listen on https
# https:
# port: 5599
# See full list of options at: https://nodejs.org/api/tls.html#tls_tls_createserver_options_secureconnectionlistener
# options:
# key: "ssl/key.pem"
# cert: "ssl/cert.pem"
# If the following is set to true then bucky server will not run in http mode
# httpsOnly: true

statsd:
host: 'localhost'
Expand All @@ -18,7 +27,7 @@ influxdb:
password: 'root'
use_udp: false
retentionPolicy: 'default'
# Acceptable version are: '0.8' and '0.9'
# All version other than '0.9' will default to '0.8'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is kind of odd. It should probably error if you try to enter a value other than '0.8' or '0.9'

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for backwards compatibility. If a user updates (or re-downloads) BuckyServer and tries to use an old default.yaml the current defaulting will handle it gracefully, and BuckyServer will act as it did before the version option was added. If this is changed to throw an error then older default.yaml configurations for InfluxDB will become invalid. Unless we add logic to only allow passed values to be '0.8', '0.9', and default undefined to '0.8'. I'll work on that last option.

version: '0.9'


Expand All @@ -32,7 +41,7 @@ modules:

collectors:
# Uncomment the modules that you'd like to use
# - ./modules/collectionLogger
- ./modules/collectionLogger
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason for turning this on by default?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the last PR I sent you I commented out all of the collectors because the log showed errors when trying to connect to databases that weren't set up. I'd have to double check but I believe I also received errors when no collectors were available. I thought it would be best to have the logger for the initial setup so that end-users won't receive errors, and they'll also be able to verify that the endpoint is being reached before adding database config. I can comment this out again if you'd like.

# - ./modules/statsd
# - ./modules/openTSDB
# - ./modules/influxdb
115 changes: 66 additions & 49 deletions lib/influxdb.coffee
Original file line number Diff line number Diff line change
@@ -1,78 +1,95 @@
request = require('request')
dgram = require('dgram')
request = require "request"
dgram = require "dgram"

class Client
constructor: (@config={}, @logger) ->
do @init

init: ->
useUDP = @config.get('influxdb.use_udp').get() ? false
useUDP = @config.get("influxdb.use_udp").get() ? false

@send = if useUDP then @sendUDP() else @sendHTTP()

write: (metrics) ->
@send @metricsJson metrics
@send @formatMetrics metrics

sendHTTP: ->
version = @config.get('influxdb.version').get() ? '0.9'
host = @config.get('influxdb.host').get() ? 'localhost'
port = @config.get('influxdb.port').get() ? 8086
database = @config.get('influxdb.database').get() ? 'bucky'
username = @config.get('influxdb.username').get() ? 'root'
password = @config.get('influxdb.password').get() ? 'root'
version = @config.get("influxdb.version").get() ? "0.9"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why double quotes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There a mix between single and double quotes, I wanted to make the quotes more uniform, I just picked one over the other. If you'd like it changed back just let me know.

Really there's no other reason (silly article): http://www.2ality.com/2012/09/javascript-quotes.html

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed these back to single quotes.

host = @config.get("influxdb.host").get() ? "localhost"
port = @config.get("influxdb.port").get() ? 8086
database = @config.get("influxdb.database").get() ? "bucky"
username = @config.get("influxdb.username").get() ? "root"
password = @config.get("influxdb.password").get() ? "root"
retentionPolicy = @config.get("influxdb.retentionPolicy").get() ? "default"
logger = @logger
if version == '0.8'
endpoint = 'http://' + host + ':' + port + '/db/' + database + '/series'
else
endpoint = 'http://' + host + ':' + port + '/write'
client = request.defaults
method: 'POST'
url: endpoint

clientConfig =
method: "POST"
qs:
u: username
p: password

(metricsJson) ->
client form: metricsJson, (error, response, body) ->
logger.log error if error
if version == "0.9"
clientConfig.url = "http://" + host + ":" + port + "/write"
clientConfig.qs.db = database
clientConfig.qs.rp = retentionPolicy
else
clientConfig.url = "http://" + host + ":" + port + "/db/" + database + "/series"

client = request.defaults clientConfig

(formatMetrics) ->
if version == "0.9"
metrics = formatMetrics.join "\n"
# uncomment to see data sent to DB
# logger.log "db: " + database + "\n" + metrics
client body: metrics, (error, response, body) ->
logger.log "Warning:" if body && body.length > 0
logger.log "\tresponse:\n", body if body && body.length > 0
logger.log error if error
else
metrics = JSON.stringify formatMetrics
# logger.log "db: " + database + "\n" + metrics
client form: metrics, (error, response, body) ->
logger.log "Warning:" if body && body.length > 0
logger.log "\tresponse:\n", body if body && body.length > 0
logger.log error if error

sendUDP: ->
host = @config.get('influxdb.host').get() ? 'localhost'
port = @config.get('influxdb.port').get() ? 4444
client = dgram.createSocket 'udp4'

(metricsJson) ->
message = new Buffer metricsJson
version = @config.get("influxdb.version").get() ? "0.9"
host = @config.get("influxdb.host").get() ? "localhost"
port = @config.get("influxdb.port").get() ? 4444
client = dgram.createSocket "udp4"

(formatMetrics) ->
if version == "0.9"
formatMetrics.forEach (metric) ->
message = new Buffer metric
client.send message, 0, message.length, port, host
else
message = new Buffer JSON.stringify formatMetrics
client.send message, 0, message.length, port, host

client.send message, 0, message.length, port, host
formatMetrics: (metrics) ->
version = @config.get("influxdb.version").get() ? "0.9"
data = []

metricsJson: (metrics) ->
version = @config.get('influxdb.version').get() ? '0.9'
if version == '0.8'
data = []
else
data =
database: @config.get('influxdb.database').get() ? 'bucky'
retentionPolicy: @config.get('influxdb.retentionPolicy').get() ? "default"
time: new Date().toISOString()
points: []
for key, desc of metrics
[val, unit, sample] = @parseRow desc

if version == '0.8'
data.push
if version == "0.9"
fields = key.replace(/\\? /g, "\\ ")
fields += " value=" + parseFloat val
fields += ',unit="' + unit.replace(/"/g, '\\"') + '"' if unit
fields += ",sample=" + sample if sample
else
fields =
name: key,
columns: ['value'],
columns: ["value"],
points: [[parseFloat val]]
else
data.points.push
measurement: key
fields:
value: parseFloat val
unit: unit
sample: sample
# @logger.log(JSON.stringify(data, null, 2))
JSON.stringify data
data.push fields

data

parseRow: (row) ->
re = /([0-9\.]+)\|([a-z]+)(?:@([0-9\.]+))?/
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bucky-server",
"version": "0.5.0",
"version": "0.6.0",
"description": "Server to collect stats from the client",
"main": "./start.js",
"bin": "./start.js",
Expand All @@ -23,7 +23,7 @@
"express": "~3.2.5",
"coffee-script": "~1.6.2",
"lynx": "~0.0.11",
"underscore": "~1.4.4",
"underscore": "~1.8.3",
"nopents": "~0.1.0",
"config": "~0.4.27",
"q": "~0.9.6",
Expand Down
40 changes: 31 additions & 9 deletions server.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
Q = require 'q'
_ = require 'underscore'
express = require 'express'
http = require 'http'

# Set cwd for config, and load config file
process.chdir(__dirname);
process.chdir __dirname
config = require 'config'

configWrapper = require './lib/configWrapper'
Expand All @@ -14,23 +15,29 @@ load = require './lib/load'
MODULES = config.modules
loadLogger = ->
if MODULES.logger
load(MODULES.logger, {config})
load MODULES.logger, {config}
else
console

# We always have the base config, but the
# app can optionally swap it out for something else.
loadConfig = (logger) ->
if MODULES.config
load(MODULES.config, {config, logger})
load MODULES.config, {config, logger}
else
configWrapper(config)
configWrapper config

setCORSHeaders = (req, res, next) ->
res.setHeader 'Access-Control-Allow-Origin', '*'
res.setHeader 'Access-Control-Allow-Methods', 'POST'
res.setHeader 'Access-Control-Max-Age', '604800'
res.setHeader 'Access-Control-Allow-Credentials', 'true'
res.setHeader 'Access-Control-Allow-Headers', 'content-type'

next()

setJSONHeader = (req, res, next) ->
req.headers['content-type'] = 'application/json'

next()

Expand Down Expand Up @@ -103,6 +110,7 @@ loadApp = (logger, loadedConfig) ->

for path, handlers of routes
# Bind all request modules as middleware and install the collectors
app.post "#{ path }/json", setJSONHeader, express.json(), setCORSHeaders, handlers...
app.post path, parser, setCORSHeaders, handlers...

app.options path, setCORSHeaders, (req, res) ->
Expand All @@ -111,15 +119,29 @@ loadApp = (logger, loadedConfig) ->
app.get "#{ APP_ROOT }/v1/health-check", (req, res) ->
res.send('OK\n')

port = process.env.PORT ? loadedConfig.get('server.port').get() ? 5000
app.listen(port)

logger.log('Server listening on port %d in %s mode', port, app.settings.env)
if loadedConfig.get('server.https.options').get() instanceof Object
https = require 'https'
fs = require 'fs'
httpsOptions = _.mapObject loadedConfig.get('server.https.options').get(), (v, k) ->
if _.isString(v)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is saying any option can also be a file? That seems very dangerous. What if my string value also happens to be a file? I'm not a big fan of errors which appear and disappear based on what files show up in the local directory.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most https certs are stored in files. The https server requires either the entire cert string or a buffer. With this logic it checks to see if the file exists then we load the contents of the file into a buffer, or keep the original string. By I added an ssl/ path to the example in default.yaml to help prevent the issue you're talking about. I don't believe any of the https options take a forward-slash as part of the option string (unless it was an entire cert string, which wouldn't match a file path). This is a shortcut around listing each and every type of option that can be a file buffer. If you'd prefer to have them spelled out I can do that.

Here are all of the available options:
https://nodejs.org/api/tls.html#tls_tls_createserver_options_secureconnectionlistener

Some additional documentation on express/connect/https

Note: Another option would be to replace default.yaml with a custom config module that returns an object. That way we could require 'fs' and allow end-users to add the options similar to the options in the nodejitsu link above and specify when the option should be a file buffer instead of a string. This would obviously not be backwards compatible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this with a co-worker and he agreed with you. Even though it's unlikely an option would share its value with a file, it's not necessary to introduce that risk.

He gave one suggestion for keeping the yaml format, which would be to pass an object containing a specified key, such as filePath, with the path.

Example:

server:
  port: 5999
  appRoot: "/bucky"
  https:
    port: 5599
    options:
      key:
        filePath: "ssl/key.pem"
      cert:
        filePath: "ssl/cert.pem"

Then to change the logic to check for an object with key "filePath" instead of a string before attempting to read in a file.

I'll implement this and update the README

try
fs.readFileSync(v)
catch
v
else
v
httpsPort = loadedConfig.get('server.https.port').get() ? (port + 1)
https.createServer(httpsOptions, app).listen httpsPort
logger.log "HTTPS Server listening on port %d in %s mode", httpsPort, app.settings.env
if !loadedConfig.get('server.httpsOnly').get()
port = process.env.PORT ? loadedConfig.get('server.port').get() ? 5000
http.createServer(app).listen port
logger.log 'HTTP Server listening on port %d in %s mode', port, app.settings.env

Q.when(loadLogger()).then (logger) ->

logger.log "Loading Config"
Q.when(loadConfig(logger)).then (loadedConfig) ->
Q.when(loadConfig logger).then (loadedConfig) ->

logger.log "Loading App"
loadApp(logger, loadedConfig)