Skip to content

Commit a217494

Browse files
bdeitteCopilot
andauthored
Fix 247 and docs on 239 (#297)
* Fix 247 and docs on 239 * Update lib/transport.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/transport.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * CHANGES updates --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 5ed593c commit a217494

File tree

4 files changed

+119
-0
lines changed

4 files changed

+119
-0
lines changed

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
CHANGELOG
22
=========
33

4+
## 13.1.0 (2026-1-24)
5+
46
* @bdeitte Add documentation for OpenTelemetry Collector StatsD receiver compatibility
57
* @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.
8+
* @bdeitte Document how to handle metrics on shutdown
9+
* @bdeitte Prevent "socket ended" errors and handle the client disconnection errors more gracefully. Fixes #247
610

711
## 13.0.0 (2026-1-19)
812

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,46 @@ it is probably because you are sending large volumes of metrics to a single agen
350350
This error only arises when using the UDS protocol and means that packages are being dropped.
351351
Take a look at the [Datadog docs](https://docs.datadoghq.com/developers/dogstatsd/high_throughput/?#over-uds-unix-domain-socket) for some tips on tuning your connection.
352352

353+
### Sending metrics during process shutdown
354+
355+
Metrics sent from `process.on('exit')` handlers will **not** be delivered. This is a fundamental Node.js limitation, not a bug in hot-shots. When the `exit` event fires, the event loop has stopped processing async operations, so socket send callbacks will never execute.
356+
357+
The same applies to `process.on('uncaughtExceptionMonitor')` since that handler is also synchronous.
358+
359+
**Alternatives that work:**
360+
361+
Use `beforeExit` for graceful shutdown (fires when event loop is empty but before exit):
362+
```javascript
363+
process.on('beforeExit', (code) => {
364+
client.increment('app.shutdown');
365+
client.close();
366+
});
367+
```
368+
369+
Use signal handlers for external shutdown requests:
370+
```javascript
371+
function gracefulShutdown(signal) {
372+
client.increment('app.shutdown', [`signal:${signal}`]);
373+
client.close(() => {
374+
process.exit(0);
375+
});
376+
}
377+
378+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
379+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
380+
```
381+
382+
For uncaught exceptions, use `uncaughtException` (not `uncaughtExceptionMonitor`) and delay exit:
383+
```javascript
384+
process.on('uncaughtException', (err) => {
385+
client.increment('app.crash');
386+
client.close(() => {
387+
console.error('Uncaught exception:', err);
388+
process.exit(1);
389+
});
390+
});
391+
```
392+
353393
## Debugging
354394

355395
If you're having issues with metrics not being sent or want to understand what hot-shots is doing

lib/transport.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ const createTcpTransport = args => {
5252
removeListener: socket.removeListener.bind(socket),
5353
send: (buf, callback) => {
5454
debug('hot-shots createTcpTransport: sending %d bytes to %s:%s', buf.length, args.host, args.port);
55+
// Check if socket is destroyed before attempting to write
56+
// This prevents ERR_STREAM_DESTROYED and "socket ended" errors (issue #247)
57+
if (socket.destroyed) {
58+
const err = new Error('Socket is destroyed');
59+
err.code = 'ERR_SOCKET_DESTROYED';
60+
debug('hot-shots createTcpTransport: socket destroyed, skipping send');
61+
if (callback) {
62+
callback(err);
63+
}
64+
return;
65+
}
5566
socket.write(addEol(buf), 'ascii', (err) => {
5667
if (err) {
5768
debug('hot-shots createTcpTransport: send error - %s', err.message);
@@ -362,6 +373,17 @@ const createStreamTransport = (args) => {
362373
removeListener: stream.removeListener.bind(stream),
363374
send: (buf, callback) => {
364375
debug('hot-shots stream transport: sending %d bytes', buf.length);
376+
// Check if stream is destroyed before attempting to write
377+
// This prevents ERR_STREAM_DESTROYED errors (issue #247)
378+
if (stream.destroyed) {
379+
const err = new Error('Stream is destroyed');
380+
err.code = 'ERR_STREAM_DESTROYED';
381+
debug('hot-shots stream transport: stream destroyed, skipping send');
382+
if (callback) {
383+
callback(err);
384+
}
385+
return;
386+
}
365387
stream.write(addEol(buf), (err) => {
366388
if (err) {
367389
debug('hot-shots stream transport: send error - %s', err.message);

test/transport.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,57 @@ describe('#transportExtended', () => {
126126
client.increment('test.metric');
127127
});
128128

129+
it('should handle write to destroyed stream gracefully (issue #247)', done => {
130+
let writeAttempted = false;
131+
132+
class TestStream extends Writable {
133+
_write(chunk, encoding, callback) { // eslint-disable-line class-methods-use-this
134+
writeAttempted = true;
135+
callback();
136+
}
137+
}
138+
139+
const stream = new TestStream();
140+
const client = new StatsD({
141+
protocol: 'stream',
142+
stream: stream,
143+
errorHandler: (error) => {
144+
// Error should be handled gracefully and identified by the expected error code
145+
assert.strictEqual(error.code, 'ERR_STREAM_DESTROYED');
146+
assert.strictEqual(writeAttempted, false, 'Should not attempt write to destroyed stream');
147+
client.close();
148+
done();
149+
}
150+
});
151+
152+
// Destroy the stream before sending - this sets stream.destroyed = true
153+
stream.destroy();
154+
155+
// This should call errorHandler with a graceful error, not throw ERR_STREAM_DESTROYED
156+
client.increment('test.metric');
157+
});
158+
159+
it('should send metric when stream is writable', done => {
160+
let writeAttempted = false;
161+
162+
class TestStream extends Writable {
163+
_write(chunk, encoding, callback) { // eslint-disable-line class-methods-use-this
164+
writeAttempted = true;
165+
callback();
166+
// Verify write was attempted
167+
assert.strictEqual(writeAttempted, true);
168+
client.close();
169+
done();
170+
}
171+
}
172+
173+
const stream = new TestStream();
174+
const client = new StatsD({
175+
protocol: 'stream',
176+
stream: stream
177+
});
178+
179+
client.increment('test.metric');
180+
});
181+
129182
});

0 commit comments

Comments
 (0)