Skip to content

Commit 5ed593c

Browse files
authored
Fix for 238 and doc update for 264 (#298)
* Fix for 238 and doc update for 264 * Fix changelog * README updates * README fix * Another README fix
1 parent a0ebed5 commit 5ed593c

File tree

7 files changed

+349
-22
lines changed

7 files changed

+349
-22
lines changed

CHANGES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
CHANGELOG
22
=========
33

4+
* @bdeitte Add documentation for OpenTelemetry Collector StatsD receiver compatibility
5+
* @bdeitte Sanitize protocol-breaking characters in metric names and tags. Fixes #238. Characters like `|`, `:`, `\n`, `#`, and `,` in metric names or tags are now replaced with `_` to prevent malformed packets.
6+
47
## 13.0.0 (2026-1-19)
58

69
* @bdeitte Breaking: Prefix and suffix now automatically include period separators if needed. If you specify `prefix: 'myapp'`, it will be normalized to `'myapp.'`. Similarly, `suffix: 'prod'` becomes `'.prod'`. This ensures metrics like `myapp.request.time` instead of `myapprequest.time`. If your prefix/suffix already includes the period, no change is needed.

README.md

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# hot-shots
22

3-
A Node.js client for [Etsy](http://etsy.com)'s [StatsD](https://github.com/etsy/statsd) server, Datadog's [DogStatsD](http://docs.datadoghq.com/guides/dogstatsd/) server, and [InfluxDB's](http://influxdb.com) [Telegraf](https://github.com/influxdb/telegraf) StatsD server.
3+
A Node.js client for Datadog's [DogStatsD](http://docs.datadoghq.com/guides/dogstatsd/) server, InfluxDB's [Telegraf](https://github.com/influxdb/telegraf) StatsD server, the OpenTelemetry Collector [StatsD receiver](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/statsdreceiver), and Etsy's [StatsD](https://github.com/etsy/statsd) server.
44

55
This project was originally a fork off of [node-statsd](https://github.com/sivy/node-statsd). This project
66
includes all changes in the latest node-statsd and many additional changes, including:
@@ -264,18 +264,63 @@ The check method has the following API:
264264
});
265265
```
266266

267-
## DogStatsD and Telegraf functionality
268-
269-
Some of the functionality mentioned above is specific to DogStatsD or Telegraf. They will not do anything if you are using the regular statsd client.
270-
* globalTags parameter- DogStatsD or Telegraf
271-
* tags parameter- DogStatsD or Telegraf.
272-
* telegraf parameter- Telegraf
273-
* uds option in protocol parameter- DogStatsD
274-
* histogram method- DogStatsD or Telegraf
275-
* event method- DogStatsD
276-
* check method- DogStatsD
277-
* includeDatadogTelemetry parameter- DogStatsD
278-
* telemetryFlushInterval parameter- DogStatsD
267+
## DogStatsD, Telegraf, and OpenTelemetry functionality
268+
269+
Some of the functionality mentioned above is specific to certain backends and will not work with others.
270+
271+
* globalTags parameter - DogStatsD, Telegraf, or OpenTelemetry
272+
* tags parameter - DogStatsD, Telegraf, or OpenTelemetry
273+
* histogram method - DogStatsD, Telegraf, or OpenTelemetry
274+
* telegraf parameter - Telegraf
275+
* uds option in protocol parameter - DogStatsD
276+
* distribution method - DogStatsD
277+
* set / unique method - DogStatsD or Telegraf (not OpenTelemetry)
278+
* event method - DogStatsD
279+
* check method - DogStatsD
280+
* timestamp option - DogStatsD
281+
* includeDatadogTelemetry parameter - DogStatsD
282+
* telemetryFlushInterval parameter - DogStatsD
283+
284+
## OpenTelemetry Collector Compatibility
285+
286+
hot-shots is compatible with the [OpenTelemetry Collector's StatsD receiver](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/statsdreceiver). The following features work out of the box:
287+
288+
| Feature | hot-shots Method | OTel Support |
289+
|---------|------------------|--------------|
290+
| Counter | `increment()`, `decrement()` | Yes |
291+
| Gauge | `gauge()` | Yes |
292+
| Gauge delta (+/-) | `gaugeDelta()` | Yes |
293+
| Timer | `timing()` | Yes (converted to gauge/summary/histogram) |
294+
| Histogram | `histogram()` | Yes (treated as timer) |
295+
| Sample rate | All methods | Yes |
296+
| Tags | All methods | Yes |
297+
298+
Example configuration for OpenTelemetry Collector:
299+
300+
```javascript
301+
var client = new StatsD({
302+
host: 'localhost',
303+
port: 8125,
304+
protocol: 'udp' // or 'tcp'
305+
});
306+
307+
// These all work with OpenTelemetry
308+
client.increment('requests');
309+
client.gauge('queue_size', 100);
310+
client.gaugeDelta('connections', 1);
311+
client.timing('response_time', 250);
312+
client.histogram('request_size', 1024);
313+
```
314+
315+
## Sanitization
316+
317+
To prevent malformed packets, hot-shots automatically replaces protocol-breaking characters with underscores (`_`).
318+
319+
* **Metric names**: `:`, `|`, `\n`
320+
* **Tag keys**: `:`, `|`, `,`, `\n`, plus `@` and `#` for StatsD/DogStatsD
321+
* **Tag values**: `|`, `,`, `\n`, plus `@` and `#` for StatsD/DogStatsD
322+
323+
Colons are allowed in tag values (e.g., `url:https://example.com:8080`).
279324

280325
## Errors
281326

lib/helpers.js

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
const fs = require('fs');
22

33
/**
4-
* Replace any characters that can't be sent on with an underscore
4+
* Replace any characters that can't be sent on with an underscore.
5+
* Used for tag keys where colons are not allowed (colon separates key from value).
56
*/
67
function sanitizeTags(value, telegraf) {
7-
const blacklist = telegraf ? /:|\||,/g : /:|\||@|,/g;
8+
// Characters that break the protocol in tag keys:
9+
// : - separates tag key from value (not allowed in keys)
10+
// | - separates metric components
11+
// , - separates tags
12+
// @ - used for sample rate (StatsD only)
13+
// # - tag prefix character (DogStatsD only)
14+
// \n - breaks the line protocol
15+
const blacklist = telegraf ? /:|\||,|\n/g : /:|\||@|,|#|\n/g;
816
// Replace reserved chars with underscores.
917
let sanitized = String(value).replace(blacklist, '_');
1018

@@ -17,16 +25,64 @@ function sanitizeTags(value, telegraf) {
1725
return sanitized;
1826
}
1927

28+
/**
29+
* Replace any characters that can't be sent on with an underscore.
30+
* Used for tag values where colons ARE allowed (e.g., URLs).
31+
*/
32+
function sanitizeTagValue(value, telegraf) {
33+
// Characters that break the protocol in tag values:
34+
// | - separates metric components
35+
// , - separates tags
36+
// @ - used for sample rate (StatsD only)
37+
// # - tag prefix character (DogStatsD only)
38+
// \n - breaks the line protocol
39+
// Note: colons ARE allowed in tag values
40+
const blacklist = telegraf ? /\||,|\n/g : /\||@|,|#|\n/g;
41+
// Replace reserved chars with underscores.
42+
let sanitized = String(value).replace(blacklist, '_');
43+
44+
// For telegraf, replace trailing backslashes as they break the line protocol
45+
// by escaping the delimiter that comes after the tag value
46+
if (telegraf && sanitized.endsWith('\\')) {
47+
sanitized = sanitized.slice(0, -1) + '_';
48+
}
49+
50+
return sanitized;
51+
}
52+
53+
/**
54+
* Replace any characters in metric names that can't be sent on with an underscore
55+
*/
56+
function sanitizeMetricName(value) {
57+
// Characters that break the protocol in metric names:
58+
// : - separates metric name from value
59+
// | - separates metric components
60+
// \n - breaks the line protocol
61+
const blacklist = /:|\||\n/g;
62+
return String(value).replace(blacklist, '_');
63+
}
64+
2065
/**
2166
* Format tags properly before sending on
2267
*/
2368
function formatTags(tags, telegraf) {
2469
if (Array.isArray(tags)) {
25-
return tags;
70+
// Sanitize each tag in the array
71+
return tags.map(tag => {
72+
// If tag contains a colon (not at position 0), sanitize key and value separately
73+
const colonIndex = typeof tag === 'string' ? tag.indexOf(':') : -1;
74+
if (colonIndex > 0) {
75+
const key = tag.substring(0, colonIndex);
76+
const value = tag.substring(colonIndex + 1);
77+
return `${sanitizeTags(key, telegraf)}:${sanitizeTagValue(value, telegraf)}`;
78+
}
79+
// For tags without colons (or colon at start), sanitize as a key (most restrictive)
80+
return sanitizeTags(tag, telegraf);
81+
});
2682

2783
} else {
2884
return Object.keys(tags).map(key => {
29-
return `${sanitizeTags(key, telegraf)}:${sanitizeTags(tags[key], telegraf)}`;
85+
return `${sanitizeTags(key, telegraf)}:${sanitizeTagValue(tags[key], telegraf)}`;
3086
});
3187
}
3288
}
@@ -149,6 +205,8 @@ module.exports = {
149205
formatDate: formatDate,
150206
getDefaultRoute: getDefaultRoute,
151207
sanitizeTags: sanitizeTags,
208+
sanitizeTagValue: sanitizeTagValue,
209+
sanitizeMetricName: sanitizeMetricName,
152210
normalizePrefix: normalizePrefix,
153211
normalizeSuffix: normalizeSuffix,
154212
// Expose intToIP for testing purposes

lib/statsd.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,9 @@ Client.prototype.sendStat = function (stat, value, type, sampleRate, tags, times
298298
this.telemetry.recordMetric(type);
299299
}
300300

301-
let message = `${this.prefix + stat + this.suffix}:${value}|${type}`;
301+
// Sanitize metric name to prevent protocol-breaking characters
302+
const sanitizedStat = helpers.sanitizeMetricName(stat);
303+
let message = `${this.prefix + sanitizedStat + this.suffix}:${value}|${type}`;
302304
sampleRate = sampleRate || this.sampleRate;
303305
if (sampleRate && sampleRate < 1) {
304306
if (Math.random() < sampleRate) {

test/childClient.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ describe('#childClient', () => {
3838
assert.strictEqual(child.prefix, 'preff.prefix.');
3939
assert.strictEqual(child.suffix, '.suffix.suff');
4040
assert.strictEqual(statsd, global.statsd);
41-
assert.deepEqual(child.globalTags, ['gtag', 'awesomeness:over9000', 'tag1:xxx', 'bar', ':baz']);
41+
// Note: ':baz' is sanitized to '_baz' because tags starting with colon are malformed
42+
assert.deepEqual(child.globalTags, ['gtag', 'awesomeness:over9000', 'tag1:xxx', 'bar', '_baz']);
4243
});
4344
});
4445

test/helpers.js

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,115 @@ describe('#helpersExtended', () => {
213213
const result = helpers.sanitizeTags('tag:with|chars\\', true);
214214
assert.strictEqual(result, 'tag_with_chars_');
215215
});
216+
217+
it('should sanitize newlines for StatsD (default)', () => {
218+
const result = helpers.sanitizeTags('tag\nvalue');
219+
assert.strictEqual(result, 'tag_value');
220+
});
221+
222+
it('should sanitize newlines for Telegraf', () => {
223+
const result = helpers.sanitizeTags('tag\nvalue', true);
224+
assert.strictEqual(result, 'tag_value');
225+
});
226+
227+
it('should sanitize hash character for StatsD (default)', () => {
228+
const result = helpers.sanitizeTags('tag#value');
229+
assert.strictEqual(result, 'tag_value');
230+
});
231+
232+
it('should not sanitize hash character for Telegraf', () => {
233+
const result = helpers.sanitizeTags('tag#value', true);
234+
assert.strictEqual(result, 'tag#value');
235+
});
236+
237+
it('should sanitize multiple protocol-breaking characters', () => {
238+
const result = helpers.sanitizeTags('tag1,tag2,\ntag3#value');
239+
assert.strictEqual(result, 'tag1_tag2__tag3_value');
240+
});
241+
});
242+
243+
describe('#sanitizeTagValue', () => {
244+
it('should not sanitize colons in tag values (they are valid)', () => {
245+
const result = helpers.sanitizeTagValue('https://example.com:8080');
246+
assert.strictEqual(result, 'https://example.com:8080');
247+
});
248+
249+
it('should sanitize pipes in tag values', () => {
250+
const result = helpers.sanitizeTagValue('value|with|pipes');
251+
assert.strictEqual(result, 'value_with_pipes');
252+
});
253+
254+
it('should sanitize newlines in tag values', () => {
255+
const result = helpers.sanitizeTagValue('value\nwith\nnewlines');
256+
assert.strictEqual(result, 'value_with_newlines');
257+
});
258+
259+
it('should sanitize commas in tag values', () => {
260+
const result = helpers.sanitizeTagValue('value,with,commas');
261+
assert.strictEqual(result, 'value_with_commas');
262+
});
263+
264+
it('should sanitize hash in tag values for StatsD (default)', () => {
265+
const result = helpers.sanitizeTagValue('value#with#hash');
266+
assert.strictEqual(result, 'value_with_hash');
267+
});
268+
269+
it('should not sanitize hash in tag values for Telegraf', () => {
270+
const result = helpers.sanitizeTagValue('value#with#hash', true);
271+
assert.strictEqual(result, 'value#with#hash');
272+
});
273+
274+
it('should sanitize at sign in tag values for StatsD (default)', () => {
275+
const result = helpers.sanitizeTagValue('value@with@at');
276+
assert.strictEqual(result, 'value_with_at');
277+
});
278+
279+
it('should not sanitize at sign in tag values for Telegraf', () => {
280+
const result = helpers.sanitizeTagValue('value@with@at', true);
281+
assert.strictEqual(result, 'value@with@at');
282+
});
283+
});
284+
285+
describe('#sanitizeMetricName', () => {
286+
it('should sanitize colons in metric names', () => {
287+
const result = helpers.sanitizeMetricName('check:value');
288+
assert.strictEqual(result, 'check_value');
289+
});
290+
291+
it('should sanitize pipes in metric names', () => {
292+
const result = helpers.sanitizeMetricName('check|value');
293+
assert.strictEqual(result, 'check_value');
294+
});
295+
296+
it('should sanitize newlines in metric names', () => {
297+
const result = helpers.sanitizeMetricName('check\nvalue');
298+
assert.strictEqual(result, 'check_value');
299+
});
300+
301+
it('should sanitize multiple protocol-breaking characters', () => {
302+
const result = helpers.sanitizeMetricName('check:11|g#this:out');
303+
assert.strictEqual(result, 'check_11_g#this_out');
304+
});
305+
306+
it('should handle non-string values', () => {
307+
const result = helpers.sanitizeMetricName(123);
308+
assert.strictEqual(result, '123');
309+
});
310+
311+
it('should handle null values', () => {
312+
const result = helpers.sanitizeMetricName(null);
313+
assert.strictEqual(result, 'null');
314+
});
315+
316+
it('should handle undefined values', () => {
317+
const result = helpers.sanitizeMetricName(undefined);
318+
assert.strictEqual(result, 'undefined');
319+
});
320+
321+
it('should preserve valid metric name characters', () => {
322+
const result = helpers.sanitizeMetricName('my.metric_name-123');
323+
assert.strictEqual(result, 'my.metric_name-123');
324+
});
216325
});
217326

218327
describe('#overrideTags - exact results verification', () => {
@@ -278,15 +387,17 @@ describe('#helpersExtended', () => {
278387
const child = [123, null, undefined, 'env:dev'];
279388
const result = helpers.overrideTags(parent, child);
280389

281-
assert.deepStrictEqual(result, ['version:1.0', 'env:dev', 123, null, undefined]);
390+
// Non-string values are converted to strings during sanitization
391+
assert.deepStrictEqual(result, ['version:1.0', 'env:dev', '123', 'null', 'undefined']);
282392
});
283393

284394
it('should handle tags with colon as first character', () => {
285395
const parent = ['normal:tag'];
286396
const child = [':invalid', 'valid:tag'];
287397
const result = helpers.overrideTags(parent, child);
288398

289-
assert.deepStrictEqual(result, ['normal:tag', 'valid:tag', ':invalid']);
399+
// ':invalid' is sanitized to '_invalid' because leading colons are invalid in tags
400+
assert.deepStrictEqual(result, ['normal:tag', 'valid:tag', '_invalid']);
290401
});
291402
});
292403

@@ -465,7 +576,8 @@ describe('#helpersExtended', () => {
465576
const child = [':invalid', 'valid:tag'];
466577
const result = helpers.overrideTags(parent, child);
467578

468-
assert(result.includes(':invalid'));
579+
// ':invalid' is sanitized to '_invalid' because leading colons are invalid
580+
assert(result.includes('_invalid'));
469581
assert(result.includes('valid:tag'));
470582
// normal:tag should remain because child doesn't override 'normal' key
471583
assert(result.includes('normal:tag'));

0 commit comments

Comments
 (0)