hot-shots
Version:
Node.js client for StatsD, DogStatsD, and Telegraf
649 lines (586 loc) • 22 kB
JavaScript
const process = require('process'),
util = require('util'),
helpers = require('./helpers'),
applyStatsFns = require('./statsFunctions');
const constants = require('./constants');
const createTransport = require('./transport');
const PROTOCOL = constants.PROTOCOL;
const TCP_ERROR_CODES = constants.tcpErrors();
const UDS_ERROR_CODES = constants.udsErrors();
const TCP_DEFAULT_GRACEFUL_RESTART_LIMIT = 1000;
const UDS_DEFAULT_GRACEFUL_RESTART_LIMIT = 1000;
const CACHE_DNS_TTL_DEFAULT = 60000;
// DD_ENV_GLOBAL_TAGS_MAPPING is a mapping of each "DD_" prefixed environment variable to a specific tag name.
const DD_ENV_GLOBAL_TAGS_MAPPING = {
DD_ENTITY_ID: 'dd.internal.entity_id', // Client-side entity ID injection for container tagging.
DD_ENV: 'env', // The name of the env in which the service runs.
DD_SERVICE: 'service', // The name of the running service.
DD_VERSION: 'version', // The current version of the running service.
};
/**
* The Client for StatsD. The main entry-point for hot-shots. Note adding new parameters
* to the constructor is deprecated- please use the constructor as one options object.
* @constructor
*/
const Client = function (host, port, prefix, suffix, globalize, cacheDns, mock,
globalTags, maxBufferSize, bufferFlushInterval, telegraf, sampleRate, protocol) {
let options = host || {};
// Adding options below is DEPRECATED. Use the options object instead.
if (arguments.length > 1 || typeof(host) === 'string') {
options = {
host : host,
port : port,
prefix : prefix,
suffix : suffix,
globalize : globalize,
cacheDns : cacheDns,
mock : mock === true,
globalTags : globalTags,
maxBufferSize : maxBufferSize,
bufferFlushInterval: bufferFlushInterval,
telegraf : telegraf,
sampleRate : sampleRate,
protocol : protocol
};
}
// hidden global_tags option for backwards compatibility
options.globalTags = options.globalTags || options.global_tags;
this.protocol = (options.protocol && options.protocol.toLowerCase());
if (! this.protocol) {
this.protocol = PROTOCOL.UDP;
}
this.cacheDns = options.cacheDns === true;
this.cacheDnsTtl = options.cacheDnsTtl || CACHE_DNS_TTL_DEFAULT;
this.host = options.host || (process.env.DD_AGENT_HOST || undefined);
this.port = options.port || parseInt(process.env.DD_DOGSTATSD_PORT, 10) || 8125;
this.path = options.path;
this.stream = options.stream;
this.prefix = options.prefix || '';
this.suffix = options.suffix || '';
this.tagPrefix = options.tagPrefix || '#';
this.tagSeparator = options.tagSeparator || ',';
this.mock = options.mock;
this.globalTags = typeof options.globalTags === 'object' ?
helpers.formatTags(options.globalTags, options.telegraf) : [];
this.includeDataDogTags = options.includeDataDogTags !== false;
if (this.includeDataDogTags) {
const availableDDEnvs = Object.keys(DD_ENV_GLOBAL_TAGS_MAPPING).filter(key => process.env[key]);
if (availableDDEnvs.length > 0) {
this.globalTags = this.globalTags.
filter((item) => !availableDDEnvs.some(env => item.startsWith(`${DD_ENV_GLOBAL_TAGS_MAPPING[env]}:`))).
concat(availableDDEnvs.map(env => `${DD_ENV_GLOBAL_TAGS_MAPPING[env]}:${helpers.sanitizeTags(process.env[env])}`));
}
}
this.telegraf = options.telegraf || false;
if (options.maxBufferSize !== undefined) {
this.maxBufferSize = options.maxBufferSize;
// For UDS protocol, enforce 8k limit
if (this.protocol === PROTOCOL.UDS && this.maxBufferSize > 8192) {
console.warn(`hot-shots: maxBufferSize (${this.maxBufferSize}) exceeds the 8192 byte limit for UDS protocol. ` +
'Setting maxBufferSize to 8192.');
this.maxBufferSize = 8192;
}
} else if (this.protocol === PROTOCOL.UDS) {
this.maxBufferSize = 8192; // 8KiB as recommended by Datadog for UDS
} else {
this.maxBufferSize = 0;
}
this.sampleRate = options.sampleRate || 1;
this.bufferFlushInterval = options.bufferFlushInterval || 1000;
this.bufferHolder = options.isChild ? options.bufferHolder : { buffer: '' };
this.errorHandler = options.errorHandler;
this.tcpGracefulErrorHandling = 'tcpGracefulErrorHandling' in options ? options.tcpGracefulErrorHandling : true;
this.tcpGracefulRestartRateLimit = options.tcpGracefulRestartRateLimit || TCP_DEFAULT_GRACEFUL_RESTART_LIMIT; // only recreate once per second
this.udsGracefulErrorHandling = 'udsGracefulErrorHandling' in options ? options.udsGracefulErrorHandling : true;
this.udsGracefulRestartRateLimit = options.udsGracefulRestartRateLimit || UDS_DEFAULT_GRACEFUL_RESTART_LIMIT; // only recreate once per second
this.isChild = options.isChild;
this.closingFlushInterval = options.closingFlushInterval || 50;
this.udpSocketOptions = options.udpSocketOptions || { type: 'udp4' };
// If we're mocking the client, create a buffer to record the outgoing calls.
if (this.mock) {
this.mockBuffer = [];
}
// We only want a single flush event per parent and all its child clients
if (!options.isChild && this.maxBufferSize > 0) {
this.intervalHandle = setInterval(this.onBufferFlushInterval.bind(this), this.bufferFlushInterval);
// do not block node from shutting down
this.intervalHandle.unref();
}
if (options.isChild) {
if (options.dnsError) {
this.dnsError = options.dnsError;
}
this.socket = options.socket;
} else if (options.useDefaultRoute) {
const defaultRoute = helpers.getDefaultRoute();
if (defaultRoute) {
console.log(`Got ${defaultRoute} for the system's default route`);
this.host = defaultRoute;
}
}
if (!this.socket) {
trySetNewSocket(this);
}
if (this.socket && !options.isChild && options.errorHandler) {
this.socket.on('error', options.errorHandler);
}
if (options.globalize) {
global.statsd = this;
}
// only for TCP/UDS (options.protocol tcp/uds)
// enabled with the extra flag options.tcpGracefulErrorHandling/options.udsGracefulErrorHandling
// will gracefully (attempt) to re-open the socket with a small delay
// options.tcpGracefulRestartRateLimit/options.udsGracefulRestartRateLimit is the minimum time (ms) between creating sockets
// does not support options.isChild (how to re-create a socket you didn't create?)
if (this.socket) {
maybeAddProtocolErrorHandler(this, options.protocol);
}
this.messagesInFlight = 0;
this.CHECKS = {
OK: 0,
WARNING: 1,
CRITICAL: 2,
UNKNOWN: 3,
};
};
applyStatsFns(Client);
/**
* Checks if stats is an array and sends all stats calling back once all have sent
* @param stat {String|Array} The stat(s) to send
* @param value The value to send
* @param type The type of the metric
* @param sampleRate {Number=} The Number of times to sample (0 to 1). Optional.
* @param tags {Array=} The Array of tags to add to metrics. Optional.
* @param callback {Function=} Callback when message is done being delivered. Optional.
*/
Client.prototype.sendAll = function (stat, value, type, sampleRate, tags, callback) {
let completed = 0;
let calledback = false;
let sentBytes = 0;
const self = this;
if (sampleRate && typeof sampleRate !== 'number') {
callback = tags;
tags = sampleRate;
sampleRate = undefined;
}
if (tags && typeof tags !== 'object') {
callback = tags;
tags = undefined;
}
/**
* Gets called once for each callback, when all callbacks return we will
* call back from the function
* @private
*/
function onSend(error, bytes) {
completed += 1;
if (calledback) {
return;
}
if (error) {
if (typeof callback === 'function') {
calledback = true;
callback(error);
} else if (self.errorHandler) {
calledback = true;
self.errorHandler(error);
}
return;
}
if (bytes) {
sentBytes += bytes;
}
if (completed === stat.length && typeof callback === 'function') {
callback(null, sentBytes);
}
}
if (Array.isArray(stat)) {
stat.forEach(item => {
self.sendStat(item, value, type, sampleRate, tags, onSend);
});
} else {
this.sendStat(stat, value, type, sampleRate, tags, callback);
}
};
/**
* Sends a stat across the wire
* @param stat {String|Array} The stat(s) to send
* @param value The value to send
* @param type {String} The type of message to send to statsd
* @param sampleRate {Number} The Number of times to sample (0 to 1)
* @param tags {Array} The Array of tags to add to metrics
* @param callback {Function=} Callback when message is done being delivered. Optional.
*/
Client.prototype.sendStat = function (stat, value, type, sampleRate, tags, callback) {
let message = `${this.prefix + stat + this.suffix}:${value}|${type}`;
sampleRate = sampleRate || this.sampleRate;
if (sampleRate && sampleRate < 1) {
if (Math.random() < sampleRate) {
message += `|@${sampleRate}`;
} else {
// don't want to send if we don't meet the sample ratio
return callback ? callback() : undefined;
}
}
this.send(message, tags, callback);
};
/**
* Send a stat or event across the wire
* @param message {String} The constructed message without tags
* @param tags {Array} The tags to include (along with global tags). Optional.
* @param callback {Function=} Callback when message is done being delivered (only if maxBufferSize == 0). Optional.
*/
Client.prototype.send = function (message, tags, callback) {
let mergedTags = this.globalTags;
if (tags && typeof tags === 'object') {
mergedTags = helpers.overrideTags(mergedTags, tags, this.telegraf);
}
if (mergedTags.length > 0) {
if (this.telegraf) {
message = message.split(':');
message = `${message[0]},${mergedTags.join(',').replace(/:/g, '=')}:${message.slice(1).join(':')}`;
} else {
message += `|${this.tagPrefix}${mergedTags.join(this.tagSeparator)}`;
}
}
this._send(message, callback);
};
/**
* Send a stat or event across the wire
* @param message {String} The constructed message without tags
* @param callback {Function=} Callback when message is done being delivered (only if maxBufferSize == 0). Optional.
*/
Client.prototype._send = function (message, callback) {
// we may have a cached error rather than a cached lookup, so
// throw it on
if (this.dnsError) {
if (callback) {
return callback(this.dnsError);
} else if (this.errorHandler) {
return this.errorHandler(this.dnsError);
}
throw this.dnsError;
}
// Only send this stat if we're not a mock Client.
if (!this.mock) {
if (this.maxBufferSize === 0) {
this.sendMessage(message, callback);
} else {
this.enqueue(message, callback);
}
} else {
this.mockBuffer.push(message);
if (typeof callback === 'function') {
callback(null, 0);
}
}
};
/**
* Add the message to the buffer and flush the buffer if needed
*
* @param message {String} The constructed message without tags
*/
Client.prototype.enqueue = function (message, callback) {
if (this.bufferHolder.buffer === '') {
this.bufferHolder.buffer += message;
}
else {
this.bufferHolder.buffer += '\n' + message;
}
if (this.bufferHolder.buffer.length > this.maxBufferSize) {
this.flushQueue(callback);
}
else if (callback) {
callback(null);
}
};
/**
* Flush the buffer, sending on the messages
*/
Client.prototype.flushQueue = function (callback) {
const currentMessage = this.bufferHolder.buffer;
this.bufferHolder.buffer = '';
this.sendMessage(currentMessage, callback);
};
/**
* Send on the message through the socket
*
* @param message {String} The constructed message without tags
* @param callback {Function=} Callback when message is done being delivered. Optional.
*/
Client.prototype.sendMessage = function (message, callback) {
// don't waste the time if we aren't sending anything
if (message === '' || this.mock) {
if (callback) {
callback();
}
return;
}
const socketWasMissing = !this.socket;
if (socketWasMissing && (this.protocol === PROTOCOL.TCP || this.protocol === PROTOCOL.UDS)) {
trySetNewSocket(this);
if (this.socket) {
// On success, add custom TCP/UDS error handling.
maybeAddProtocolErrorHandler(this, this.protocol, Date.now());
}
}
if (socketWasMissing) {
const error = new Error('Socket not created properly. Check previous errors for details.');
if (callback) {
return callback(error);
} else if (this.errorHandler) {
return this.errorHandler(error);
} else {
return console.error(String(error));
}
}
const handleCallback = (err, bytes) => {
this.messagesInFlight--;
const errFormatted = err ? new Error(`Error sending hot-shots message: ${err}`) : null;
if (errFormatted) {
errFormatted.code = err.code;
// handle TCP/UDS error that requires socket replacement when we are not
// emitting the `error` event on `this.socket`
if ((this.protocol === PROTOCOL.TCP || this.protocol === PROTOCOL.UDS) && (callback || this.errorHandler)) {
protocolErrorHandler(this, this.protocol, err);
}
}
if (callback) {
callback(errFormatted, bytes);
} else if (errFormatted) {
if (this.errorHandler) {
this.errorHandler(errFormatted);
} else {
console.error(String(errFormatted));
// emit error ourselves on the socket for backwards compatibility
this.socket.emit('error', errFormatted);
}
}
};
try {
this.messagesInFlight++;
this.socket.send(Buffer.from(message), handleCallback);
} catch (err) {
handleCallback(err);
}
};
/**
* Called every bufferFlushInterval to flush any buffer that is around
*/
Client.prototype.onBufferFlushInterval = function () {
this.flushQueue();
};
/**
* Close the underlying socket and stop listening for data on it.
*/
Client.prototype.close = function (callback) {
// stop trying to flush the queue on an interval
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
}
// flush the queue one last time, if needed
this.flushQueue((err) => {
if (err) {
if (callback) {
return callback(err);
}
else {
return console.error(err);
}
}
// FIXME: we have entered callback hell, and this whole file is in need of an async rework
// wait until there are no more messages in flight before really closing the socket
let intervalAttempts = 0;
const waitForMessages = setInterval(() => {
intervalAttempts++;
if (intervalAttempts > 10) {
console.log('hot-shots could not clear out messages in flight but closing anyways');
this.messagesInFlight = 0;
}
if (this.messagesInFlight <= 0) {
clearInterval(waitForMessages);
this._close(callback);
}
}, this.closingFlushInterval);
});
};
/**
* Really close the socket and handle any errors related to it
*/
Client.prototype._close = function (callback) {
// If there was an error creating it, nothing to do here
if (! this.socket) {
if (callback) {
callback();
}
return;
}
// error function to use in callback and catch below
let handledError = false;
const handleErr = (err) => {
const errMessage = `Error closing hot-shots socket: ${err}`;
if (handledError) {
console.error(errMessage);
}
else {
// The combination of catch and error can lead to some errors
// showing up twice. So we just show one of the errors that occur
// on close.
handledError = true;
if (callback) {
callback(new Error(errMessage));
} else if (this.errorHandler) {
this.errorHandler(new Error(errMessage));
} else {
console.error(errMessage);
}
}
};
if (this.errorHandler) {
this.socket.removeListener('error', this.errorHandler);
}
// handle error and close events
this.socket.on('error', handleErr);
if (callback) {
this.socket.on('close', err => {
if (! handledError && callback) {
callback(err);
}
});
}
try {
this.socket.close();
} catch (err) {
handleErr(err);
}
};
const ChildClient = function (parent, options) {
options = options || {};
Client.call(this, {
isChild : true,
socket : parent.socket, // Child inherits socket from parent. Parent itself can be a child.
// All children and parent share the same buffer via sharing an object (cannot mutate strings)
bufferHolder: parent.bufferHolder,
dnsError : parent.dnsError, // Child inherits an error from parent (if it is there)
errorHandler: options.errorHandler || parent.errorHandler, // Handler for callback errors
host : parent.host,
port : parent.port,
tagPrefix : parent.tagPrefix,
tagSeparator : parent.tagSeparator,
prefix : (options.prefix || '') + parent.prefix, // Child has its prefix prepended to parent's prefix
suffix : parent.suffix + (options.suffix || ''), // Child has its suffix appended to parent's suffix
globalize : false, // Only 'root' client can be global
mock : parent.mock,
// Append child's tags to parent's tags
globalTags : typeof options.globalTags === 'object' ?
helpers.overrideTags(parent.globalTags, options.globalTags, parent.telegraf) : parent.globalTags,
includeDataDogTags: parent.includeDataDogTags,
maxBufferSize : parent.maxBufferSize,
bufferFlushInterval: parent.bufferFlushInterval,
telegraf : parent.telegraf,
protocol : parent.protocol,
closingFlushInterval : parent.closingFlushInterval
});
};
util.inherits(ChildClient, Client);
/**
* Creates a child client that adds prefix, suffix and/or tags to this client. Child client can itself have children.
* @param options
* @option prefix {String} An optional prefix to assign to each stat name sent
* @option suffix {String} An optional suffix to assign to each stat name sent
* @option globalTags {Array=} Optional tags that will be added to every metric
*/
Client.prototype.childClient = function (options) {
return new ChildClient(this, options);
};
exports = module.exports = Client;
exports.StatsD = Client;
/**
* Detect and handle an error connecting to a TCP/UDS socket. This will
* attempt to create a new socket and replace and close the client's current
* socket, registering a **new** `protocolErrorHandler()` on the newly created socket.
* If a new socket can't be created (e.g. if no TCP/UDS currently exists at
* `client.path`) then this will leave the existing socket intact.
*
* Note that this will no-op with an early exit if the last socket create time
* was too recent (within the TCP/UDS graceful restart rate limit).
* @param client Client The statsd Client that may be getting a TCP/UDS error handler.
* @param protocol Client configured protocol
* @param err The error that we will handle if a TCP/UDS connection error is detected.
*/
function protocolErrorHandler(client, protocol, err) {
if (!err || !client.socket || !client.socket.createdAt) {
return;
}
// recreate the socket, but only once within `tcpGracefulRestartRateLimit`/`udsGracefulRestartRateLimit`.
if (protocol === PROTOCOL.TCP && (!TCP_ERROR_CODES.includes(-err.code) || Date.now() - client.socket.createdAt < client.tcpGracefulRestartRateLimit)) {
return;
} else if (protocol === PROTOCOL.UDS && (!UDS_ERROR_CODES.includes(-err.code) || Date.now() - client.socket.createdAt < client.udsGracefulRestartRateLimit)) {
return;
}
if (client.errorHandler) {
client.socket.removeListener('error', client.errorHandler);
}
const newSocket = createTransport(client, {
host: client.host,
path: client.path,
port: client.port,
protocol: client.protocol,
});
if (newSocket) {
client.socket.close();
client.socket = newSocket;
maybeAddProtocolErrorHandler(client, protocol);
} else {
const errorMessage = `Could not replace ${protocol} connection with new socket`;
if (client.errorHandler) {
client.errorHandler(new Error(errorMessage));
} else {
console.error(errorMessage);
}
return;
}
if (client.errorHandler) {
client.socket.on('error', client.errorHandler);
} else {
client.socket.on('error', (error) => console.error(`hot-shots ${protocol} error: ${error}`));
}
}
/**
* Add a TCP/UDS socket error handler to the client's socket, if the
* client is not a "child" client and has graceful error handling enabled for
* TCP/UDS.
* @param client Client The statsd Client that may be getting a TCP/UDS error handler.
* @param protocol Client configured protocol
*/
function maybeAddProtocolErrorHandler(client, protocol) {
if (client.isChild) {
return;
}
if ((protocol === PROTOCOL.TCP && !client.tcpGracefulErrorHandling) || (protocol === PROTOCOL.UDS && !client.udsGracefulErrorHandling)) {
return;
}
if (protocol === PROTOCOL.TCP || protocol === PROTOCOL.UDS) {
client.socket.on('error', (err) => {
protocolErrorHandler(client, protocol, err);
});
}
}
/**
* Try to replace a client's socket with a new transport. If `createTransport()`
* returns `null` this will still set the client's socket to `null`. This also
* updates the socket creation time for UDS error handling.
* @param client Client The statsd Client that will be getting a new socket
*/
function trySetNewSocket(client) {
client.socket = createTransport(client, {
host: client.host,
cacheDns: client.cacheDns,
cacheDnsTtl: client.cacheDnsTtl,
path: client.path,
port: client.port,
protocol: client.protocol,
stream: client.stream,
udpSocketOptions: client.udpSocketOptions,
});
}