hot-shots
Version:
Node.js client for StatsD, DogStatsD, and Telegraf
503 lines (470 loc) • 19 kB
JavaScript
const assert = require('assert');
const dgram = require('dgram');
const net = require('net');
const dns = require('dns');
const os = require('os');
const util = require('util');
const { PROTOCOL } = require('./constants');
const debug = util.debuglog('hot-shots');
// Imported below, only if needed
let unixDgram;
const UDS_PATH_DEFAULT = '/var/run/datadog/dsd.socket';
/**
* Ensures a buffer ends with a newline character for line-based protocols.
* @param {Buffer} buf - The buffer to check and modify
* @returns {string} The buffer content as a string with newline appended if needed
*/
const addEol = (buf) => {
let msg = buf.toString();
if (msg.length > 0 && msg[msg.length - 1] !== '\n') {
msg += '\n';
}
return msg;
};
// interface Transport {
// emit(name: string, payload: any):void;
// on(name: string, listener: Function):void;
// removeListener(name: string, listener: Function):void;
// send(buf: Buffer, callback: Function):void;
// close():void;
// unref(): void;
// }
/**
* Creates a TCP transport for persistent connection-based metric delivery.
* Automatically adds newlines to messages and maintains keep-alive connection.
* @param {Object} args - Configuration options including host and port
* @returns {Transport} A transport object implementing the Transport interface
*/
const createTcpTransport = args => {
debug('hot-shots createTcpTransport: connecting to %s:%s', args.host, args.port);
const socket = net.connect(args.port, args.host);
socket.setKeepAlive(true);
// do not block node from shutting down
socket.unref();
return {
emit: socket.emit.bind(socket),
on: socket.on.bind(socket),
removeListener: socket.removeListener.bind(socket),
send: (buf, callback) => {
debug('hot-shots createTcpTransport: sending %d bytes to %s:%s', Buffer.byteLength(buf), args.host, args.port);
// Check if socket is destroyed before attempting to write
// This prevents ERR_STREAM_DESTROYED and "socket ended" errors (issue #247)
if (socket.destroyed) {
const err = new Error('Socket is destroyed');
err.code = 'ERR_SOCKET_DESTROYED';
debug('hot-shots createTcpTransport: socket destroyed, skipping send');
if (callback) {
callback(err);
}
return;
}
socket.write(addEol(buf), 'ascii', (err) => {
if (err) {
debug('hot-shots createTcpTransport: send error - %s', err.message);
} else {
debug('hot-shots createTcpTransport: send successful');
}
if (callback) {
callback(err);
}
});
},
close: () => {
debug('hot-shots createTcpTransport: closing connection');
socket.destroy();
},
unref: socket.unref.bind(socket)
};
};
/**
* Creates a UDP transport for connectionless metric delivery with optional DNS caching.
* Optimizes for IP addresses to avoid unnecessary DNS lookups and APM instrumentation overhead.
* Auto-detects IPv6 addresses and uses the appropriate socket type (udp4 or udp6).
* @param {Object} args - Configuration options including host, port, cacheDns, cacheDnsTtl, and udpSocketOptions
* @returns {Transport} A transport object implementing the Transport interface
*/
const createUdpTransport = args => {
debug('hot-shots createUdpTransport: creating socket for %s:%s (cacheDns=%s)', args.host, args.port, args.cacheDns);
// Optimize for IP addresses to avoid unnecessary dns.lookup calls
// This prevents APM tools from instrumenting dns.lookup for IP addresses
const socketOptions = Object.assign({}, args.udpSocketOptions);
// Auto-detect socket type based on host IP version if not explicitly set
// This fixes issues on Node.js 17+ where localhost may resolve to ::1 (IPv6)
const ipVersion = args.host ? net.isIP(args.host) : 0;
if (ipVersion && !socketOptions.type) {
socketOptions.type = ipVersion === 6 ? 'udp6' : 'udp4';
debug('hot-shots createUdpTransport: auto-detected socket type %s for IP version %d', socketOptions.type, ipVersion);
} else if (!socketOptions.type) {
// Default to udp4 for hostnames when no type is specified
socketOptions.type = 'udp4';
}
if (!socketOptions.lookup && ipVersion) {
debug('hot-shots createUdpTransport: detected IP address (v%d), using optimized lookup', ipVersion);
socketOptions.lookup = (hostname, options, callback) => {
// Handle both lookup(hostname, callback) and lookup(hostname, options, callback) signatures
if (typeof options === 'function') {
callback = options;
}
// Bypass dns.lookup for IP addresses to avoid APM instrumentation overhead
callback(null, hostname, ipVersion);
};
}
const socket = dgram.createSocket(socketOptions);
// do not block node from shutting down
socket.unref();
const dnsResolutionData = {
timestamp: new Date(0),
resolvedAddress: undefined
};
/**
* Sends a buffer to the UDP socket at the specified address with error handling.
* @param {Buffer} buf - The data buffer to send
* @param {string} address - The resolved IP address to send to
* @param {Function} callback - Callback function to invoke after send completes
*/
const sendToSocket = (buf, address, callback) => {
try {
debug('hot-shots UDP transport: sending %d bytes to %s:%s', Buffer.byteLength(buf), address, args.port);
socket.send(buf, 0, buf.length, args.port, address, (err) => {
if (err) {
debug('hot-shots UDP transport: send error - %s', err.message);
} else {
debug('hot-shots UDP transport: send successful (note: UDP does not guarantee delivery)');
}
if (callback) {
callback(err);
}
});
} catch (socketError) {
debug('hot-shots UDP transport: send exception - %s', socketError.message);
if (callback) {
callback(socketError);
}
}
};
/**
* Sends data using cached DNS resolution to avoid repeated lookups.
* Caches resolved addresses for the configured TTL duration.
* @param {Function} callback - Callback function to invoke after send completes
* @param {Buffer} buf - The data buffer to send
*/
const sendUsingDnsCache = (callback, buf) => {
const now = Date.now();
if (dnsResolutionData.resolvedAddress === undefined || (now - dnsResolutionData.timestamp > args.cacheDnsTtl)) {
debug('hot-shots UDP transport: performing DNS lookup for %s', args.host);
// Optimize: if host is already an IP, skip dns.lookup
const hostIpVersion = net.isIP(args.host);
if (hostIpVersion) {
debug('hot-shots UDP transport: host is already an IP address (v%d), skipping DNS lookup', hostIpVersion);
dnsResolutionData.resolvedAddress = args.host;
dnsResolutionData.timestamp = now;
sendToSocket(buf, dnsResolutionData.resolvedAddress, callback);
return;
}
dns.lookup(args.host, (error, address) => {
if (error) {
debug('hot-shots UDP transport: DNS lookup error - %s', error.message);
callback(error);
return;
}
debug('hot-shots UDP transport: DNS resolved %s to %s', args.host, address);
dnsResolutionData.resolvedAddress = address;
dnsResolutionData.timestamp = now;
sendToSocket(buf, dnsResolutionData.resolvedAddress, callback);
});
} else {
debug('hot-shots UDP transport: using cached DNS address %s', dnsResolutionData.resolvedAddress);
sendToSocket(buf, dnsResolutionData.resolvedAddress, callback);
}
};
return {
emit: socket.emit.bind(socket),
on: socket.on.bind(socket),
removeListener: socket.removeListener.bind(socket),
send: function (buf, callback) {
if (args.cacheDns) {
sendUsingDnsCache(callback, buf);
} else {
try {
debug('hot-shots UDP transport: sending %d bytes to %s:%s (no DNS cache)', Buffer.byteLength(buf), args.host, args.port);
socket.send(buf, 0, buf.length, args.port, args.host, (err) => {
if (err) {
debug('hot-shots UDP transport: send error - %s', err.message);
} else {
debug('hot-shots UDP transport: send successful (note: UDP does not guarantee delivery)');
}
if (callback) {
callback(err);
}
});
} catch (socketError) {
debug('hot-shots UDP transport: send exception - %s', socketError.message);
callback(socketError);
}
}
},
close: () => {
debug('hot-shots UDP transport: closing socket');
socket.close();
},
unref: socket.unref.bind(socket)
};
};
/**
* Creates a Unix Domain Socket (UDS) transport for local IPC metric delivery.
* Implements automatic retry logic with exponential backoff for EAGAIN and congestion errors.
* Requires the optional unix-dgram dependency to be installed.
* @param {Object} args - Configuration options including path and udsRetryOptions
* @returns {Transport} A transport object implementing the Transport interface
*/
const createUdsTransport = args => {
try {
// This will not always be available, as noted in the error message below
unixDgram = require('unix-dgram'); // eslint-disable-line global-require
} catch (err) {
throw new Error(
'The library `unix_dgram`, needed for the uds protocol to work, is not installed. ' +
'You need to pick another protocol to use hot-shots. ' +
'See the hot-shots README for additional details.'
);
}
// Only retry-related options live here now
const udsOpts = args.udsRetryOptions || {};
const udsPath = args.path ? args.path : UDS_PATH_DEFAULT;
debug('hot-shots createUdsTransport: connecting to %s', udsPath);
const socket = unixDgram.createSocket('unix_dgram');
try {
socket.connect(udsPath);
debug('hot-shots createUdsTransport: connected successfully');
} catch (err) {
debug('hot-shots createUdsTransport: connection failed - %s', err.message);
socket.close();
throw err;
}
// Retry configuration with defaults (milliseconds)
const maxRetries = (udsOpts.retries === undefined || udsOpts.retries === null) ? 3 : udsOpts.retries;
const initialDelayMs = (udsOpts.retryDelayMs === undefined || udsOpts.retryDelayMs === null) ? 100 : udsOpts.retryDelayMs;
const maxDelayMs = (udsOpts.maxRetryDelayMs === undefined || udsOpts.maxRetryDelayMs === null) ? 1000 : udsOpts.maxRetryDelayMs;
const backoffFactor = (udsOpts.backoffFactor === undefined || udsOpts.backoffFactor === null) ? 2 : udsOpts.backoffFactor;
const EAGAIN = os.constants && os.constants.errno && os.constants.errno.EAGAIN;
/**
* Checks if an error is an EAGAIN error (resource temporarily unavailable).
* @param {Error} err - The error to check
* @returns {boolean} True if the error is EAGAIN
*/
const isEagain = (err) => {
if (!err) {
return false;
}
if (err.code === 'EAGAIN') {
return true;
}
return typeof err.errno === 'number' && typeof EAGAIN === 'number' && err.errno === EAGAIN;
};
/**
* Checks if an error is a congestion error from unix-dgram.
* unix-dgram returns an internal 'congestion' error (err === 1) via callback.
* @param {Error} err - The error to check
* @returns {boolean} True if the error is a congestion error
*/
const isCongestion = (err) => {
if (!err) {
return false;
}
if (err.code === 'congestion' || err.message === 'congestion') {
return true;
}
// Some builds may expose the sentinel as errno===1
return err.errno === 1;
};
/**
* Checks if an error is retryable for UDS transport (EAGAIN or congestion).
* @param {Error} err - The error to check
* @returns {boolean} True if the error should be retried
*/
const isRetryableUdsError = (err) => isEagain(err) || isCongestion(err);
/**
* Sends data to UDS socket with automatic retry logic using exponential backoff.
* Retries on EAGAIN and congestion errors up to the configured maximum retry count.
* @param {Buffer} buf - The data buffer to send
* @param {Function} callback - Callback function to invoke after send completes or fails
* @param {number} attempt - Current retry attempt number (default: 0)
*/
const sendWithRetry = (buf, callback, attempt = 0) => {
if (attempt === 0) {
debug('hot-shots UDS transport: sending %d bytes', buf.length);
} else {
debug('hot-shots UDS transport: retry attempt %d/%d', attempt, maxRetries);
}
socket.send(buf, (err) => {
if (err && isRetryableUdsError(err) && attempt < maxRetries) {
const delay = Math.min(initialDelayMs * Math.pow(backoffFactor, attempt), maxDelayMs);
debug('hot-shots UDS transport: retryable error (%s), retrying after %dms', err.message || err.code || err, delay);
setTimeout(() => sendWithRetry(buf, callback, attempt + 1), delay);
} else if (err) {
debug('hot-shots UDS transport: send error - %s (attempts: %d)', err.message || err.code || err, attempt + 1);
if (typeof callback === 'function') {
callback(err);
}
} else {
debug('hot-shots UDS transport: send successful (attempts: %d)', attempt + 1);
if (typeof callback === 'function') {
callback(err);
}
}
});
};
return {
emit: socket.emit.bind(socket),
on: socket.on.bind(socket),
removeListener: socket.removeListener.bind(socket),
send: sendWithRetry,
close: () => {
socket.close();
// close is synchronous, and the socket will not emit a
// close event, hence emulating standard behaviour by doing this:
socket.emit('close');
},
unref: () => {
throw new Error('unix-dgram does not implement unref for sockets');
}
};
};
/**
* Creates a stream transport using a provided raw stream for metric delivery.
* Automatically adds newlines to messages. Useful for custom transport implementations.
* @param {Object} args - Configuration options, must include a stream property
* @returns {Transport} A transport object implementing the Transport interface
*/
const createStreamTransport = (args) => {
const stream = args.stream;
assert(stream, '`stream` option required');
debug('hot-shots createStreamTransport: using provided stream');
return {
emit: stream.emit.bind(stream),
on: stream.on.bind(stream),
removeListener: stream.removeListener.bind(stream),
send: (buf, callback) => {
debug('hot-shots stream transport: sending %d bytes', buf.length);
// Check if stream is destroyed before attempting to write
// This prevents ERR_STREAM_DESTROYED errors (issue #247)
if (stream.destroyed) {
const err = new Error('Stream is destroyed');
err.code = 'ERR_STREAM_DESTROYED';
debug('hot-shots stream transport: stream destroyed, skipping send');
if (callback) {
callback(err);
}
return;
}
stream.write(addEol(buf), (err) => {
if (err) {
debug('hot-shots stream transport: send error - %s', err.message);
} else {
debug('hot-shots stream transport: send successful');
}
if (callback) {
callback(err);
}
});
},
close: () => {
debug('hot-shots stream transport: closing stream');
stream.destroy();
// Node v8 doesn't fire `close` event on stream destroy.
if (process.version.split('.').shift() === 'v8') {
stream.emit('close');
}
},
unref: () => {
throw new Error('stream transport does not support unref');
}
};
};
/**
* Creates a mock transport that doesn't create actual sockets.
* Used when mock mode is enabled to avoid unnecessary socket creation and connection attempts.
* @returns {Transport} A mock transport object implementing the Transport interface
*/
const createMockTransport = () => {
debug('hot-shots createMockTransport: creating mock transport (no actual socket)');
const listeners = {};
const mockSocket = {
emit: (event, ...args) => {
debug('hot-shots mock transport: emit called for event=%s', event);
if (listeners[event]) {
listeners[event].forEach(listener => listener(...args));
}
},
on: (event, listener) => {
debug('hot-shots mock transport: on called for event=%s', event);
if (!listeners[event]) {
listeners[event] = [];
}
listeners[event].push(listener);
},
removeListener: (event, listener) => {
debug('hot-shots mock transport: removeListener called for event=%s', event);
if (listeners[event]) {
listeners[event] = listeners[event].filter(l => l !== listener);
}
},
send: (buf, callback) => {
debug('hot-shots mock transport: send called with %d bytes', buf.length);
if (typeof callback === 'function') {
callback(null, buf.length);
}
},
close: () => {
debug('hot-shots mock transport: close called');
// Emit close event asynchronously to match real socket behavior
setImmediate(() => {
mockSocket.emit('close');
});
},
unref: () => {
debug('hot-shots mock transport: unref called');
}
};
return mockSocket;
};
/**
* Factory function that creates the appropriate transport based on the protocol specified in args.
* Handles errors by invoking the instance's errorHandler or logging to console.
* @param {Object} instance - The StatsD client instance
* @param {Object} args - Configuration options including protocol, host, port, and protocol-specific options
* @returns {Transport|null} A transport object with a type property, or null if creation failed
*/
module.exports = (instance, args) => {
let transport = null;
const protocol = args.protocol || PROTOCOL.UDP;
try {
if (args.mock) {
// In mock mode, create a mock transport that doesn't create actual sockets
transport = createMockTransport(args);
transport.type = 'mock';
} else if (protocol === PROTOCOL.TCP) {
transport = createTcpTransport(args);
transport.type = protocol;
} else if (protocol === PROTOCOL.UDS) {
transport = createUdsTransport(args);
transport.type = protocol;
} else if (protocol === PROTOCOL.UDP) {
transport = createUdpTransport(args);
transport.type = protocol;
} else if (protocol === PROTOCOL.STREAM) {
transport = createStreamTransport(args);
transport.type = protocol;
} else {
throw new Error(`Unsupported protocol '${protocol}'`);
}
transport.createdAt = Date.now();
} catch (e) {
if (instance.errorHandler) {
instance.errorHandler(e);
} else {
console.error(e);
}
}
return transport;
};