diff --git a/CHANGES.md b/CHANGES.md index 7378232..a55b17e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,13 +1,15 @@ CHANGELOG ========= +* @bdeitte Add client-side telemetry support with `includeDatadogTelemetry` option (disabled by default and in beta) and telemetryFlushInterval + ## 12.0.0 (2025-12-16) * @bdeitte event calls now use prefix and suffix * @bdeitte mock mode no longer creates a socket * @bdeitte using an IP no longer invokes DNS lookup * @bdeitte client close no longer fails when errorHandler is defined but socket is null -* @bdeitte tags ending with '\' no longer breaks telegraph +* @bdeitte tags ending with '\\' no longer breaks telegraph ## 11.4.0 (2025-12-7) diff --git a/CLAUDE.md b/CLAUDE.md index 971bc13..0973d4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ hot-shots is a Node.js client library for StatsD, DogStatsD (Datadog), and Teleg - **lib/statsFunctions.js**: Core metric methods (timing, increment, gauge, etc.) - **lib/helpers.js**: Tag formatting, sanitization, and utility functions - **lib/constants.js**: Protocol constants and error codes +- **lib/telemetry.js**: Client-side telemetry for DogStatsD (tracks metrics/bytes sent/dropped) - **index.js**: Main entry point (exports lib/statsd.js) - **types.d.ts**: TypeScript type definitions @@ -40,7 +41,13 @@ npm run coverage # Run tests with coverage report ``` ### Linting -The project uses ESLint 5.x with pretest hooks. All code must pass linting before tests run. +The project uses ESLint 8.x with pretest hooks. All code must pass linting before tests run. + +Key linting rules to follow: +- Use single quotes for strings (not double quotes or backticks for simple strings) +- Always use curly braces for if/else blocks, even single-line ones +- Ternary operators: put `?` and `:` at the end of lines, not the beginning +- No trailing spaces or mixed indentation ### Running Single Tests ```bash @@ -77,6 +84,13 @@ npx mocha test/specific-test.js --timeout 5000 - Distribution metrics - Automatic DD_* environment variable tag injection - Unix Domain Socket support +- Client-side telemetry (opt-in via `includeDatadogTelemetry`) + +### DogStatsD-Only Features Pattern +Features specific to DogStatsD (not Telegraf) should: +1. Check `this.telegraf` and throw/return error if true +2. Be disabled in mock mode where appropriate +3. Child clients should inherit parent behavior (e.g., share telemetry instance) ### Telegraf - Different tag separator format @@ -91,6 +105,14 @@ The project uses Mocha with 5-second timeouts. Tests are organized by feature: - Error handling and edge cases - Child client functionality - Buffering and performance tests +- Telemetry tests + +### Test Helpers +Tests use helpers from `test/helpers/helpers.js`: +- `createServer(serverType, callback)` - Creates a test server for the given protocol +- `createHotShotsClient(opts, clientType)` - Creates a client ('client', 'child client', etc.) +- `closeAll(server, statsd, allowErrors, done)` - Properly closes server and client in afterEach +- `testTypes()` - Returns all protocol/client combinations for parameterized tests ## Dependencies diff --git a/README.md b/README.md index 89d237e..c28001b 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ Parameters (specified as one object passed into hot-shots): * `maxRetryDelayMs`: Maximum delay in milliseconds between retry attempts (caps exponential backoff). Defaults to `1000`. * `backoffFactor`: Exponential backoff multiplier for retry delays. Defaults to `2`. * `udpSocketOptions`: Used only when the protocol is `udp`. Specify the options passed into dgram.createSocket(). Defaults to `{ type: 'udp4' }` +* `includeDatadogTelemetry`: Enable client-side telemetry to track metrics about the client itself. This helps diagnose high-throughput metric delivery issues. Telemetry metrics are prefixed with `datadog.dogstatsd.client.` and are not billed as custom metrics. `default: false`. See [Client-Side Telemetry](#client-side-telemetry) for details. +* `telemetryFlushInterval`: When telemetry is enabled, how often (in ms) to send telemetry metrics. `default: 10000` ### StatsD methods All StatsD methods other than `event`, `close`, and `check` have the same API: @@ -257,6 +259,8 @@ Some of the functionality mentioned above is specific to DogStatsD or Telegraf. * histogram method- DogStatsD or Telegraf * event method- DogStatsD * check method- DogStatsD +* includeDatadogTelemetry parameter- DogStatsD +* telemetryFlushInterval parameter- DogStatsD ## Errors @@ -309,6 +313,46 @@ optionalDependency, and how it's used in the codebase, this install failure will not cause any problems. It only means that you can't use the uds feature. +## Datadog Telemetry + +When `includeDatadogTelemetry` is enabled, the client automatically sends telemetry metrics about itself to help diagnose metric delivery issues in high-throughput scenarios. This feature should matche the behavior of official Datadog clients as described in [the docs](https://docs.datadoghq.com/developers/dogstatsd/high_throughput/?tab=go#client-side-telemetry). + +Telemetry is automatically disabled when using `mock: true`, `telegraf: true`, or in child clients. + +### Telemetry Metrics + +The following metrics are sent every `telemetryFlushInterval` milliseconds (default: 10 seconds): + +| Metric | Description | +|--------|-------------| +| `datadog.dogstatsd.client.metrics` | Total number of metrics sent | +| `datadog.dogstatsd.client.metrics_by_type` | Metrics broken down by type (gauge, count, set, timing, histogram, distribution) | +| `datadog.dogstatsd.client.events` | Total number of events sent | +| `datadog.dogstatsd.client.service_checks` | Total number of service checks sent | +| `datadog.dogstatsd.client.bytes_sent` | Total bytes successfully sent | +| `datadog.dogstatsd.client.bytes_dropped` | Total bytes dropped | +| `datadog.dogstatsd.client.packets_sent` | Total packets successfully sent | +| `datadog.dogstatsd.client.packets_dropped` | Total packets dropped | + +The `metric_dropped_on_receive` from the official Datadog clients is intentionally omitted. That metric tracks drops on an internal receive channel, which doesn't apply to hot-shots' architecture. Also `bytes_dropped_queue` is omitted as this also didn't fit into how hot-shots works. + +### Telemetry Tags + +All telemetry metrics include these tags: +* `client:nodejs` - Identifies the hot-shots client +* `client_version:` - The hot-shots version +* `client_transport:` - The transport protocol (udp, tcp, uds, stream) + +### Example + +```javascript +var client = new StatsD({ + host: 'localhost', + includeDatadogTelemetry: true, + telemetryFlushInterval: 10000 // Optional, default is 10 seconds +}); +``` + ## Submitting changes Thanks for considering making any updates to this project! This project is entirely community-driven, and so your changes are important. Here are the steps to take in your fork: diff --git a/lib/statsFunctions.js b/lib/statsFunctions.js index d4a1a55..969f00f 100644 --- a/lib/statsFunctions.js +++ b/lib/statsFunctions.js @@ -258,6 +258,11 @@ function applyStatsFns (Client) { throw err; } + // Track service check in telemetry + if (this.telemetry) { + this.telemetry.recordServiceCheck(); + } + const check = ['_sc', this.prefix + name + this.suffix, status], metadata = options || {}; if (metadata.date_happened) { @@ -304,9 +309,9 @@ function applyStatsFns (Client) { * @option date_happened {Date} Assign a timestamp to the event. Default is now. * @option hostname {String} Assign a hostname to the event. * @option aggregation_key {String} Assign an aggregation key to the event, to group it with some others. - * @option priority {String} Can be ‘normal’ or ‘low’. Default is 'normal'. + * @option priority {String} Can be 'normal' or 'low'. Default is 'normal'. * @option source_type_name {String} Assign a source type to the event. - * @option alert_type {String} Can be ‘error’, ‘warning’, ‘info’ or ‘success’. Default is 'info'. + * @option alert_type {String} Can be 'error', 'warning', 'info' or 'success'. Default is 'info'. * @param tags {Array=} The Array of tags to add to metrics. Optional. * @param callback {Function=} Callback when message is done being delivered. Optional. */ @@ -323,6 +328,11 @@ function applyStatsFns (Client) { throw err; } + // Track event in telemetry + if (this.telemetry) { + this.telemetry.recordEvent(); + } + // Convert to strings let message; diff --git a/lib/statsd.js b/lib/statsd.js index 9f8ad0d..5b74d43 100644 --- a/lib/statsd.js +++ b/lib/statsd.js @@ -6,6 +6,7 @@ const process = require('process'), const constants = require('./constants'); const createTransport = require('./transport'); const debug = util.debuglog('hot-shots'); +const Telemetry = require('./telemetry'); const PROTOCOL = constants.PROTOCOL; const TCP_ERROR_CODES = constants.tcpErrors(); @@ -107,6 +108,29 @@ const Client = function (host, port, prefix, suffix, globalize, cacheDns, mock, this.closingFlushInterval = options.closingFlushInterval || 50; this.udpSocketOptions = options.udpSocketOptions || { type: 'udp4' }; + // Telemetry options (Datadog-specific, disabled by default) + // Only enable for non-telegraf, non-mock, non-child clients + this.includeDatadogTelemetry = options.includeDatadogTelemetry === true && + !options.telegraf && + !options.mock && + !options.isChild; + + // Initialize telemetry if enabled + if (this.includeDatadogTelemetry) { + this.telemetryFlushInterval = options.telemetryFlushInterval || Telemetry.DEFAULT_TELEMETRY_FLUSH_INTERVAL; + this.telemetry = new Telemetry({ + protocol: this.protocol, + flushInterval: this.telemetryFlushInterval, + tagPrefix: this.tagPrefix, + tagSeparator: this.tagSeparator + }); + } else if (options.isChild && options.telemetry) { + // Child clients share parent's telemetry instance + this.telemetry = options.telemetry; + } else { + this.telemetry = null; + } + // If we're mocking the client, create a buffer to record the outgoing calls. if (this.mock) { this.mockBuffer = []; @@ -144,6 +168,16 @@ const Client = function (host, port, prefix, suffix, globalize, cacheDns, mock, global.statsd = this; } + // Start telemetry if enabled (only for parent clients) + if (this.includeDatadogTelemetry && this.telemetry) { + // Set the send function for telemetry to use + // We use sendMessage directly to bypass metric tracking (avoid infinite loop) + this.telemetry.setSendFunction((message, callback) => { + this.sendMessage(message, callback, true); // true = isTelemetry + }); + this.telemetry.start(); + } + debug('hot-shots client initialized: protocol=%s, host=%s, port=%s, prefix=%s, maxBufferSize=%s, mock=%s', this.protocol, this.host, this.port, this.prefix, this.maxBufferSize, this.mock); @@ -243,6 +277,11 @@ Client.prototype.sendAll = function (stat, value, type, sampleRate, tags, callba * @param callback {Function=} Callback when message is done being delivered. Optional. */ Client.prototype.sendStat = function (stat, value, type, sampleRate, tags, callback) { + // Track metric in telemetry (even if sampled out, matching official Datadog behavior) + if (this.telemetry) { + this.telemetry.recordMetric(type); + } + let message = `${this.prefix + stat + this.suffix}:${value}|${type}`; sampleRate = sampleRate || this.sampleRate; if (sampleRate && sampleRate < 1) { @@ -357,8 +396,9 @@ Client.prototype.flushQueue = function (callback) { * * @param message {String} The constructed message without tags * @param callback {Function=} Callback when message is done being delivered. Optional. + * @param isTelemetry {Boolean=} Whether this is a telemetry message (to avoid tracking telemetry). Optional. */ -Client.prototype.sendMessage = function (message, callback) { +Client.prototype.sendMessage = function (message, callback, isTelemetry) { // don't waste the time if we aren't sending anything if (message === '' || this.mock) { if (callback) { @@ -367,6 +407,9 @@ Client.prototype.sendMessage = function (message, callback) { return; } + const messageBytes = Buffer.byteLength(message); + debug('hot-shots sendMessage: message size in bytes is %d', messageBytes); + const socketWasMissing = !this.socket; if (socketWasMissing && (this.protocol === PROTOCOL.TCP || this.protocol === PROTOCOL.UDS)) { debug('hot-shots sendMessage: socket missing, attempting to recreate for protocol=%s', this.protocol); @@ -381,6 +424,10 @@ Client.prototype.sendMessage = function (message, callback) { if (socketWasMissing) { const error = new Error('Socket not created properly. Check previous errors for details.'); debug('hot-shots sendMessage: socket creation failed - %s', error.message); + // Track bytes dropped due to socket error (only for non-telemetry messages) + if (this.telemetry && !isTelemetry) { + this.telemetry.recordBytesDroppedWriter(messageBytes); + } if (callback) { return callback(error); } else if (this.errorHandler) { @@ -396,6 +443,10 @@ Client.prototype.sendMessage = function (message, callback) { if (errFormatted) { errFormatted.code = err.code; debug('hot-shots sendMessage: error sending - %s (code: %s)', err.message, err.code); + // Track bytes dropped due to writer error (only for non-telemetry messages) + if (this.telemetry && !isTelemetry) { + this.telemetry.recordBytesDroppedWriter(messageBytes); + } // 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)) { @@ -403,6 +454,10 @@ Client.prototype.sendMessage = function (message, callback) { } } else { debug('hot-shots sendMessage: successfully sent %d bytes', message.length); + // Track bytes sent successfully (only for non-telemetry messages) + if (this.telemetry && !isTelemetry) { + this.telemetry.recordBytesSent(messageBytes); + } } if (callback) { callback(errFormatted, bytes); @@ -444,6 +499,12 @@ Client.prototype.close = function (callback) { clearInterval(this.intervalHandle); } + // Stop telemetry and flush one last time + if (this.includeDatadogTelemetry && this.telemetry) { + this.telemetry.stop(); + this.telemetry.flush(); // Final flush before close + } + // flush the queue one last time, if needed this.flushQueue((err) => { if (err) { @@ -554,7 +615,9 @@ const ChildClient = function (parent, options) { bufferFlushInterval: parent.bufferFlushInterval, telegraf : parent.telegraf, protocol : parent.protocol, - closingFlushInterval : parent.closingFlushInterval + closingFlushInterval : parent.closingFlushInterval, + // Child inherits telemetry from parent (for metric tracking) + telemetry : parent.telemetry }); }; util.inherits(ChildClient, Client); diff --git a/lib/telemetry.js b/lib/telemetry.js new file mode 100644 index 0000000..2672d1c --- /dev/null +++ b/lib/telemetry.js @@ -0,0 +1,299 @@ +const util = require('util'); +const debug = util.debuglog('hot-shots'); + +// Version is read from package.json (with fallback if unavailable or malformed) +let version = 'unknown'; +try { + const pkg = require('../package.json'); // eslint-disable-line global-require + if (pkg && typeof pkg.version === 'string') { + version = pkg.version; + } +} catch (err) { + debug('hot-shots telemetry: failed to load package.json version: %s', err && err.message ? err.message : err); +} + +// Default flush interval matches official Datadog clients (10 seconds) +const DEFAULT_TELEMETRY_FLUSH_INTERVAL = 10000; + +// Metric type code to telemetry type name mapping +const TYPE_MAP = { + 'c': 'count', + 'g': 'gauge', + 'ms': 'timing', + 'h': 'histogram', + 'd': 'distribution', + 's': 'set' +}; + +/** + * Telemetry class for tracking client-side metrics about the StatsD client itself. + * This helps diagnose high-throughput metric delivery issues by tracking: + * - Number of metrics/events/service checks sent + * - Bytes and packets sent successfully + * - Bytes and packets dropped (due to queue overflow or writer errors) + * + * Telemetry metrics are prefixed with 'datadog.dogstatsd.client.' and are not billed + * as custom metrics by Datadog. + */ +class Telemetry { + /** + * @param {Object} options + * @param {string} options.protocol - Transport protocol (udp, tcp, uds, stream) + * @param {number} options.flushInterval - Interval in ms between telemetry flushes (default: 10000) + * @param {Object} options.globalTags - Global tags from the client (not used for telemetry) + * @param {string} options.tagPrefix - Tag prefix from the client + * @param {string} options.tagSeparator - Tag separator from the client + */ + constructor(options) { + this.protocol = options.protocol || 'udp'; + this.flushInterval = options.flushInterval || DEFAULT_TELEMETRY_FLUSH_INTERVAL; + this.tagPrefix = options.tagPrefix || '#'; + this.tagSeparator = options.tagSeparator || ','; + + // Build telemetry-specific tags (not user's globalTags) + this.telemetryTags = [ + 'client:nodejs', + `client_version:${version}`, + `client_transport:${this.protocol}` + ]; + + // Initialize counters + this.resetCounters(); + + // Reference to the client's send function (set via setSendFunction) + this.sendFn = null; + + // Interval handle for periodic flushing + this.intervalHandle = null; + + debug('hot-shots telemetry: initialized with protocol=%s, flushInterval=%d', this.protocol, this.flushInterval); + } + + /** + * Reset all telemetry counters to zero. + * Called after each flush to report differential values. + */ + resetCounters() { + // Metric counters + this.metrics = 0; + this.metricsByType = { + count: 0, + gauge: 0, + timing: 0, + histogram: 0, + distribution: 0, + set: 0 + }; + this.events = 0; + this.serviceChecks = 0; + + // Transmission counters + this.bytesSent = 0; + this.bytesDropped = 0; + this.bytesDroppedWriter = 0; + this.packetsSent = 0; + this.packetsDropped = 0; + this.packetsDroppedQueue = 0; + this.packetsDroppedWriter = 0; + } + + /** + * Set the send function to use for flushing telemetry. + * This is called by the client after initialization. + * @param {Function} sendFn - Function that sends a message (message, callback) + */ + setSendFunction(sendFn) { + this.sendFn = sendFn; + } + + /** + * Start the telemetry flush interval. + * Should be called after the client is fully initialized. + */ + start() { + if (this.intervalHandle) { + return; // Already started + } + + this.intervalHandle = setInterval(() => { + this.flush(); + }, this.flushInterval); + + // Do not block node from shutting down + this.intervalHandle.unref(); + + debug('hot-shots telemetry: started flush interval (every %dms)', this.flushInterval); + } + + /** + * Stop the telemetry flush interval. + */ + stop() { + if (this.intervalHandle) { + clearInterval(this.intervalHandle); + this.intervalHandle = null; + debug('hot-shots telemetry: stopped flush interval'); + } + } + + /** + * Record a metric being sent. + * @param {string} type - The metric type code (c, g, ms, h, d, s) + */ + recordMetric(type) { + this.metrics++; + const typeName = TYPE_MAP[type]; + if (typeName && this.metricsByType[typeName] !== undefined) { + this.metricsByType[typeName]++; + } + debug('hot-shots telemetry: recordMetric type=%s, total=%d', type, this.metrics); + } + + /** + * Record an event being sent. + */ + recordEvent() { + this.events++; + debug('hot-shots telemetry: recordEvent total=%d', this.events); + } + + /** + * Record a service check being sent. + */ + recordServiceCheck() { + this.serviceChecks++; + debug('hot-shots telemetry: recordServiceCheck total=%d', this.serviceChecks); + } + + /** + * Record bytes successfully sent. + * @param {number} bytes - Number of bytes sent + */ + recordBytesSent(bytes) { + this.bytesSent += bytes; + this.packetsSent++; + debug('hot-shots telemetry: recordBytesSent bytes=%d, totalBytes=%d, totalPackets=%d', + bytes, this.bytesSent, this.packetsSent); + } + + /** + * Record bytes dropped due to writer/transport errors. + * @param {number} bytes - Number of bytes dropped + */ + recordBytesDroppedWriter(bytes) { + this.bytesDropped += bytes; + this.bytesDroppedWriter += bytes; + this.packetsDropped++; + this.packetsDroppedWriter++; + debug('hot-shots telemetry: recordBytesDroppedWriter bytes=%d, totalDropped=%d', bytes, this.bytesDropped); + } + + /** + * Format a telemetry metric message. + * @param {string} name - Metric name (without prefix) + * @param {number} value - Metric value + * @param {Array} extraTags - Additional tags to include + * @returns {string} Formatted metric message + */ + formatMetric(name, value, extraTags = []) { + const fullName = `datadog.dogstatsd.client.${name}`; + const allTags = extraTags.length > 0 ? + [...this.telemetryTags, ...extraTags] : + this.telemetryTags; + return `${fullName}:${value}|c|${this.tagPrefix}${allTags.join(this.tagSeparator)}`; + } + + /** + * Flush all telemetry metrics. + * Sends accumulated counters and resets them. + * @param {Function} callback - Optional callback when flush is complete + */ + flush(callback) { + if (!this.sendFn) { + debug('hot-shots telemetry: flush skipped - no send function set'); + if (callback) { + callback(); + } + return; + } + + const messages = []; + + // Metrics count + if (this.metrics > 0) { + messages.push(this.formatMetric('metrics', this.metrics)); + } + + // Metrics by type + for (const [typeName, count] of Object.entries(this.metricsByType)) { + if (count > 0) { + messages.push(this.formatMetric('metrics_by_type', count, [`metrics_type:${typeName}`])); + } + } + + // Events count + if (this.events > 0) { + messages.push(this.formatMetric('events', this.events)); + } + + // Service checks count + if (this.serviceChecks > 0) { + messages.push(this.formatMetric('service_checks', this.serviceChecks)); + } + + // Bytes sent + if (this.bytesSent > 0) { + messages.push(this.formatMetric('bytes_sent', this.bytesSent)); + } + + // Bytes dropped + if (this.bytesDropped > 0) { + messages.push(this.formatMetric('bytes_dropped', this.bytesDropped)); + } + + // Bytes dropped by writer + if (this.bytesDroppedWriter > 0) { + messages.push(this.formatMetric('bytes_dropped_writer', this.bytesDroppedWriter)); + } + + // Packets sent + if (this.packetsSent > 0) { + messages.push(this.formatMetric('packets_sent', this.packetsSent)); + } + + // Packets dropped + if (this.packetsDropped > 0) { + messages.push(this.formatMetric('packets_dropped', this.packetsDropped)); + } + + // Packets dropped by queue + if (this.packetsDroppedQueue > 0) { + messages.push(this.formatMetric('packets_dropped_queue', this.packetsDroppedQueue)); + } + + // Packets dropped by writer + if (this.packetsDroppedWriter > 0) { + messages.push(this.formatMetric('packets_dropped_writer', this.packetsDroppedWriter)); + } + + // Reset counters before sending (to capture new activity during send) + this.resetCounters(); + + if (messages.length === 0) { + debug('hot-shots telemetry: flush - no metrics to send'); + if (callback) { + callback(); + } + return; + } + + debug('hot-shots telemetry: flushing %d telemetry metrics', messages.length); + + // Send all telemetry messages + const message = messages.join('\n'); + this.sendFn(message, callback); + } +} + +module.exports = Telemetry; +module.exports.DEFAULT_TELEMETRY_FLUSH_INTERVAL = DEFAULT_TELEMETRY_FLUSH_INTERVAL; diff --git a/test/telemetry.js b/test/telemetry.js new file mode 100644 index 0000000..c926299 --- /dev/null +++ b/test/telemetry.js @@ -0,0 +1,406 @@ +const assert = require('assert'); +const helpers = require('./helpers/helpers.js'); + +const closeAll = helpers.closeAll; +const createServer = helpers.createServer; +const createHotShotsClient = helpers.createHotShotsClient; + +const Telemetry = require('../lib/telemetry'); + +describe('#telemetry', () => { + let server; + let statsd; + + afterEach(done => { + closeAll(server, statsd, false, done); + server = null; + statsd = null; + }); + + describe('initialization', () => { + it('should be disabled by default', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(opts, 'client'); + assert.strictEqual(statsd.includeDatadogTelemetry, false); + assert.strictEqual(statsd.telemetry, null); + done(); + }); + }); + + it('should be enabled when includeDatadogTelemetry is true', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true + }), 'client'); + assert.strictEqual(statsd.includeDatadogTelemetry, true); + assert.ok(statsd.telemetry instanceof Telemetry); + done(); + }); + }); + + it('should use default flush interval of 10 seconds', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true + }), 'client'); + assert.strictEqual(statsd.telemetryFlushInterval, 10000); + done(); + }); + }); + + it('should allow custom flush interval', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true, + telemetryFlushInterval: 5000 + }), 'client'); + assert.strictEqual(statsd.telemetryFlushInterval, 5000); + done(); + }); + }); + + it('should be disabled for telegraf mode', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true, + telegraf: true + }), 'client'); + assert.strictEqual(statsd.includeDatadogTelemetry, false); + assert.strictEqual(statsd.telemetry, null); + done(); + }); + }); + + it('should be disabled for mock mode', () => { + statsd = createHotShotsClient({ + includeDatadogTelemetry: true, + mock: true + }, 'client'); + assert.strictEqual(statsd.includeDatadogTelemetry, false); + assert.strictEqual(statsd.telemetry, null); + }); + }); + + describe('child clients', () => { + it('should inherit telemetry from parent', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true + }), 'client'); + const child = statsd.childClient({ prefix: 'child.' }); + assert.strictEqual(child.telemetry, statsd.telemetry); + child.close(); + done(); + }); + }); + + it('child metrics should be tracked in parent telemetry', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true + }), 'client'); + const child = statsd.childClient({ prefix: 'child.' }); + + // Send a metric from child + child.increment('test'); + + // Check that parent's telemetry tracked it + assert.strictEqual(statsd.telemetry.metrics, 1); + assert.strictEqual(statsd.telemetry.metricsByType.count, 1); + child.close(); + done(); + }); + }); + }); + + describe('metric tracking', () => { + it('should track increment metrics', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true + }), 'client'); + + statsd.increment('test.counter'); + assert.strictEqual(statsd.telemetry.metrics, 1); + assert.strictEqual(statsd.telemetry.metricsByType.count, 1); + done(); + }); + }); + + it('should track gauge metrics', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true + }), 'client'); + + statsd.gauge('test.gauge', 42); + assert.strictEqual(statsd.telemetry.metrics, 1); + assert.strictEqual(statsd.telemetry.metricsByType.gauge, 1); + done(); + }); + }); + + it('should track timing metrics', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true + }), 'client'); + + statsd.timing('test.timing', 100); + assert.strictEqual(statsd.telemetry.metrics, 1); + assert.strictEqual(statsd.telemetry.metricsByType.timing, 1); + done(); + }); + }); + + it('should track histogram metrics', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true + }), 'client'); + + statsd.histogram('test.histogram', 50); + assert.strictEqual(statsd.telemetry.metrics, 1); + assert.strictEqual(statsd.telemetry.metricsByType.histogram, 1); + done(); + }); + }); + + it('should track distribution metrics', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true + }), 'client'); + + statsd.distribution('test.distribution', 25); + assert.strictEqual(statsd.telemetry.metrics, 1); + assert.strictEqual(statsd.telemetry.metricsByType.distribution, 1); + done(); + }); + }); + + it('should track set metrics', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true + }), 'client'); + + statsd.set('test.set', 'value'); + assert.strictEqual(statsd.telemetry.metrics, 1); + assert.strictEqual(statsd.telemetry.metricsByType.set, 1); + done(); + }); + }); + + it('should track multiple metrics of different types', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true + }), 'client'); + + statsd.increment('test.counter'); + statsd.gauge('test.gauge', 42); + statsd.timing('test.timing', 100); + + assert.strictEqual(statsd.telemetry.metrics, 3); + assert.strictEqual(statsd.telemetry.metricsByType.count, 1); + assert.strictEqual(statsd.telemetry.metricsByType.gauge, 1); + assert.strictEqual(statsd.telemetry.metricsByType.timing, 1); + done(); + }); + }); + + it('should track events', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true + }), 'client'); + + statsd.event('Test Event', 'This is a test event'); + assert.strictEqual(statsd.telemetry.events, 1); + done(); + }); + }); + + it('should track service checks', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true + }), 'client'); + + statsd.check('test.check', statsd.CHECKS.OK); + assert.strictEqual(statsd.telemetry.serviceChecks, 1); + done(); + }); + }); + }); + + describe('Telemetry class', () => { + it('should format metrics correctly', () => { + const telemetry = new Telemetry({ + protocol: 'udp', + tagPrefix: '#', + tagSeparator: ',' + }); + + const message = telemetry.formatMetric('metrics', 10); + assert.ok(message.includes('datadog.dogstatsd.client.metrics:10|c')); + assert.ok(message.includes('client:nodejs')); + assert.ok(message.includes('client_transport:udp')); + }); + + it('should include extra tags when provided', () => { + const telemetry = new Telemetry({ + protocol: 'tcp', + tagPrefix: '#', + tagSeparator: ',' + }); + + const message = telemetry.formatMetric('metrics_by_type', 5, ['metrics_type:count']); + assert.ok(message.includes('datadog.dogstatsd.client.metrics_by_type:5|c')); + assert.ok(message.includes('metrics_type:count')); + }); + + it('should reset counters after flush', () => { + const telemetry = new Telemetry({ + protocol: 'udp', + tagPrefix: '#', + tagSeparator: ',' + }); + + // Set a mock send function + const sentMessages = []; + telemetry.setSendFunction((message, callback) => { + sentMessages.push(message); + if (callback) { + callback(); + } + }); + + // Record some metrics + telemetry.recordMetric('c'); + telemetry.recordMetric('g'); + telemetry.recordEvent(); + telemetry.recordServiceCheck(); + telemetry.recordBytesSent(100); + + assert.strictEqual(telemetry.metrics, 2); + assert.strictEqual(telemetry.events, 1); + assert.strictEqual(telemetry.serviceChecks, 1); + assert.strictEqual(telemetry.bytesSent, 100); + + // Flush + telemetry.flush(); + + // Counters should be reset + assert.strictEqual(telemetry.metrics, 0); + assert.strictEqual(telemetry.events, 0); + assert.strictEqual(telemetry.serviceChecks, 0); + assert.strictEqual(telemetry.bytesSent, 0); + + // Should have sent telemetry + assert.strictEqual(sentMessages.length, 1); + }); + + it('should not send telemetry when no metrics recorded', () => { + const telemetry = new Telemetry({ + protocol: 'udp', + tagPrefix: '#', + tagSeparator: ',' + }); + + const sentMessages = []; + telemetry.setSendFunction((message, callback) => { + sentMessages.push(message); + if (callback) { + callback(); + } + }); + + telemetry.flush(); + assert.strictEqual(sentMessages.length, 0); + }); + + it('should track bytes dropped by writer', () => { + const telemetry = new Telemetry({ + protocol: 'udp', + tagPrefix: '#', + tagSeparator: ',' + }); + + telemetry.recordBytesDroppedWriter(50); + assert.strictEqual(telemetry.bytesDropped, 50); + assert.strictEqual(telemetry.bytesDroppedWriter, 50); + assert.strictEqual(telemetry.packetsDropped, 1); + assert.strictEqual(telemetry.packetsDroppedWriter, 1); + }); + }); + + describe('telemetry flush', () => { + it('should send telemetry metrics to server', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true, + telemetryFlushInterval: 100 // Short interval for testing + }), 'client'); + + // Send some metrics + statsd.increment('test.counter'); + statsd.gauge('test.gauge', 42); + + // Wait for telemetry flush + server.on('metrics', metrics => { + if (metrics.includes('datadog.dogstatsd.client.metrics')) { + assert.ok(metrics.includes('datadog.dogstatsd.client.metrics')); + assert.ok(metrics.includes('client:nodejs')); + assert.ok(metrics.includes('client_transport:udp')); + done(); + } + }); + }); + }); + + it('should flush telemetry on close', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true, + telemetryFlushInterval: 60000 // Long interval + }), 'client'); + + // Send some metrics + statsd.increment('test.counter'); + + // Verify telemetry has data + assert.strictEqual(statsd.telemetry.metrics, 1); + + // Close should flush telemetry + statsd.close(() => { + // After close, telemetry should have been flushed (counters reset) + // Note: We can't easily verify the final flush was sent since the socket closes + // Set statsd to null to prevent afterEach from trying to close again + statsd = null; + done(); + }); + }); + }); + }); + + describe('bytes tracking', () => { + it('should track bytes sent on successful send', done => { + server = createServer('udp', opts => { + statsd = createHotShotsClient(Object.assign(opts, { + includeDatadogTelemetry: true + }), 'client'); + + statsd.increment('test.counter', 1, () => { + // Give a moment for the callback to complete + setTimeout(() => { + assert.ok(statsd.telemetry.bytesSent > 0); + assert.strictEqual(statsd.telemetry.packetsSent, 1); + done(); + }, 50); + }); + }); + }); + }); +}); diff --git a/types.d.ts b/types.d.ts index 50648cc..14fc75b 100644 --- a/types.d.ts +++ b/types.d.ts @@ -40,6 +40,8 @@ declare module "hot-shots" { maxRetryDelayMs?: number; backoffFactor?: number; }; + includeDatadogTelemetry?: boolean; + telemetryFlushInterval?: number; } export interface ChildClientOptions {