ntlogger 2.10.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plugins/README.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  NightTimeLogger is a powerful and flexible logging utility with various plugins to extend its functionality. This README provides an overview of the available plugins and instructions on how to set them up.
4
4
 
5
+ ## Required Plugin Entry Fields
6
+
7
+ Every entry in the `plugins` array **must** supply all three of the following. The
8
+ loader silently skips a plugin whose `enabled` is missing/falsy, and reports and skips
9
+ a plugin whose `config` is missing or is not an object:
10
+
11
+ | Field | Required | Notes |
12
+ | --- | --- | --- |
13
+ | `name` | yes | Must be one of `Sentry`, `MySQL`, `Postgres`, `Jest`, `Syslog`, `Discord`, `Teams`, `OpenObserve`, `OpenTelemetry`. Unknown names are reported and skipped. |
14
+ | `enabled` | yes | Must be `true`. A plugin with `enabled: false` (or omitted) is skipped. |
15
+ | `config` | yes | Must be an object, even if empty (`config: {}`). |
16
+
17
+ ## Optional Peer Dependencies
18
+
19
+ The heavier plugin backends are **optional peer dependencies**: installing `ntlogger`
20
+ does not pull them in, so projects that do not use those plugins stay lean. Install the
21
+ peer for each plugin you actually enable:
22
+
23
+ | Plugin | Install |
24
+ | --- | --- |
25
+ | Sentry | `npm install @sentry/node` |
26
+ | MySQL | `npm install mysql2` |
27
+ | Postgres | `npm install pg` |
28
+ | OpenTelemetry | `npm install @opentelemetry/api @opentelemetry/api-logs @opentelemetry/sdk-logs @opentelemetry/exporter-logs-otlp-http @opentelemetry/resources @opentelemetry/semantic-conventions` |
29
+ | Discord, Teams, OpenObserve, Syslog, Jest | none - no extra packages required |
30
+
31
+ If the peer is missing, the plugin loader reports an actionable error naming the
32
+ package and the install command, then skips that plugin. Every other plugin in the
33
+ same configuration still loads and the logger keeps working.
34
+
5
35
  ## Available Plugins
6
36
 
7
37
  ### 1. Discord
@@ -9,10 +39,13 @@ NightTimeLogger is a powerful and flexible logging utility with various plugins
9
39
 
10
40
  ![Discord Plugin Output](https://github.com/NightSquawk/NightTimeLogger/blob/main/images/plugins/discord/pluginDiscordOutput.png)
11
41
 
42
+ **Peer dependency:** none.
43
+
12
44
  **Setup:**
13
45
  ```javascript
14
46
  {
15
47
  name: 'Discord',
48
+ enabled: true,
16
49
  config: {
17
50
  webhookUrl: process.env.DISCORD_WEBHOOK_URL
18
51
  avatarUrl: process.env.DISCORD_AVATAR_URL || 'https://pbs.twimg.com/profile_images/997535493624508416/V7Ed1k2o_400x400.jpg',
@@ -26,11 +59,15 @@ NightTimeLogger is a powerful and flexible logging utility with various plugins
26
59
  ### 2. Sentry
27
60
  Description: Initializes the Sentry SDK for error tracking and defines a custom Winston transport for capturing and sending error logs to Sentry.
28
61
 
62
+ **Peer dependency:** `npm install @sentry/node` (plus `@sentry/profiling-node` if you use
63
+ `nodeProfilingIntegration()`).
64
+
29
65
  Setup:
30
66
 
31
67
  ``` javascript
32
68
  {
33
69
  name: 'Sentry',
70
+ enabled: true,
34
71
  config: {
35
72
  dsn: process.env.SENTRY_DSN || null,
36
73
  release: process.env.SENTRY_RELEASE || null,
@@ -54,11 +91,14 @@ Setup:
54
91
  ### 3. MySQL
55
92
  Description: Stores logs in a MySQL database, allowing you to persist log messages and analyze them using SQL queries.
56
93
 
94
+ **Peer dependency:** `npm install mysql2`.
95
+
57
96
  Setup:
58
97
 
59
98
  ``` javascript
60
99
  {
61
100
  name: 'MySQL',
101
+ enabled: true,
62
102
  config: {
63
103
  host: process.env.MYSQL_DB_HOST || 'localhost',
64
104
  port: process.env.MYSQL_DB_PORT || 3306,
@@ -73,11 +113,16 @@ Setup:
73
113
  ### 4. Jest
74
114
  Description: Stores log messages in memory for testing purposes, particularly useful when running tests with Jest.
75
115
 
76
- Setup:
116
+ **Peer dependency:** none.
117
+
118
+ Setup (`enabled` and an object `config` are both required, even though the Jest
119
+ transport takes no options):
77
120
 
78
121
  ``` javascript
79
122
  {
80
123
  name: 'Jest',
124
+ enabled: true,
125
+ config: {},
81
126
  }
82
127
  ```
83
128
 
@@ -87,11 +132,14 @@ Description: Sends logs to a Syslog server using a custom Syslog client. This al
87
132
  ![Syslog Plugin Output](https://github.com/NightSquawk/NightTimeLogger/blob/main/images/plugins/syslog/pluginSyslogOutput.png)
88
133
 
89
134
 
135
+ **Peer dependency:** none.
136
+
90
137
  Setup:
91
138
 
92
139
  ``` javascript
93
140
  {
94
141
  name: 'Syslog',
142
+ enabled: true,
95
143
  config: {
96
144
  host: process.env.SYSLOG_HOST || 'localhost',
97
145
  port: process.env.SYSLOG_PORT || 514,
@@ -111,11 +159,14 @@ Setup:
111
159
  ![Teams Mixed Object Plugin Output](https://github.com/NightSquawk/NightTimeLogger/blob/main/images/plugins/teams/pluginTeamsMixedOutput.png)
112
160
 
113
161
 
162
+ **Peer dependency:** none.
163
+
114
164
  **Setup:**
115
165
 
116
166
  ``` javascript
117
167
  {
118
168
  name: 'Teams',
169
+ enabled: true,
119
170
  config: {
120
171
  webhookUrl: process.env.TEAMS_WEBHOOK_URL || "im not leaking my webhook url again",
121
172
  level: process.env.LOG_LEVEL || "info",
@@ -236,11 +287,14 @@ log.error('Error message',[
236
287
  ### 7. OpenObserve
237
288
  **Description:** Sends logs to OpenObserve, an open-source observability platform. This plugin automatically strips ANSI escape codes, removes timestamps, and sends clean structured JSON logs to OpenObserve's HTTP API with batching support for improved performance.
238
289
 
290
+ **Peer dependency:** none.
291
+
239
292
  **Setup:**
240
293
 
241
294
  ```javascript
242
295
  {
243
296
  name: 'OpenObserve',
297
+ enabled: true,
244
298
  config: {
245
299
  host: process.env.OPENOBSERVE_HOST || 'http://localhost:5080',
246
300
  organization: process.env.OPENOBSERVE_ORGANIZATION || 'default',
@@ -261,6 +315,60 @@ log.error('Error message',[
261
315
  - Clean structured JSON output suitable for OpenObserve ingestion
262
316
  - Support for all log levels with configurable filtering
263
317
 
318
+ ### 8. OpenTelemetry
319
+ **Description:** Sends logs to an OpenTelemetry collector as OTLP log records. Maps ntlogger levels to OTel `SeverityNumber`/`SeverityText`, strips ANSI colour codes, flattens metadata into OTel attributes (nested objects are JSON-stringified), and automatically attaches `trace_id`/`span_id` from the active span when `@opentelemetry/api` is installed.
320
+
321
+ **Peer dependency:** the OpenTelemetry packages are optional peers:
322
+
323
+ ```bash
324
+ npm install @opentelemetry/api @opentelemetry/api-logs @opentelemetry/sdk-logs \
325
+ @opentelemetry/exporter-logs-otlp-http @opentelemetry/resources \
326
+ @opentelemetry/semantic-conventions
327
+ ```
328
+
329
+ If your application already builds its own OTel pipeline, pass an existing
330
+ `loggerProvider` and none of the SDK packages are required by this plugin.
331
+
332
+ **Setup:**
333
+
334
+ ```javascript
335
+ {
336
+ name: 'OpenTelemetry',
337
+ enabled: true,
338
+ config: {
339
+ // --- Collector endpoint (used when no `exporter`/`loggerProvider` is given) ---
340
+ url: process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT || 'http://localhost:4318/v1/logs',
341
+ headers: { 'x-api-key': process.env.OTEL_API_KEY },
342
+ concurrencyLimit: 10, // Optional: max in-flight export requests
343
+ timeoutMillis: 10000, // Optional: export request timeout
344
+
345
+ // --- Resource attributes ---
346
+ serviceName: 'my-service', // Optional: sets service.name
347
+ serviceVersion: '1.2.3', // Optional: sets service.version
348
+ resourceAttributes: { 'deployment.environment': process.env.NODE_ENV },
349
+ // resource: myResource, // Optional: a pre-built OTel Resource (wins over the above)
350
+
351
+ // --- Pipeline ---
352
+ // exporter: myExporter, // Optional: bring your own exporter (gRPC/protobuf/etc.)
353
+ // loggerProvider: myProvider, // Optional: reuse an existing LoggerProvider
354
+ useSimpleProcessor: false, // Optional: SimpleLogRecordProcessor instead of Batch
355
+ processorOptions: {}, // Optional: passed to the processor constructor
356
+
357
+ // --- Behaviour ---
358
+ level: 'trace', // Optional: minimum level to export (default: 'trace')
359
+ includeTraceContext: true, // Optional: attach trace_id/span_id (default: true)
360
+ stripAnsi: true, // Optional: strip ANSI codes (default: true)
361
+ instrumentationName: 'ntlogger', // Optional: OTel instrumentation scope name
362
+ instrumentationVersion: '3.0.0', // Optional: OTel instrumentation scope version
363
+ },
364
+ }
365
+ ```
366
+
367
+ **Notes:**
368
+ - `level` uses ntlogger priorities (`fatal` 0 -> `internal` 6). The default `'trace'` exports everything except `internal`; use `'internal'` to export absolutely everything.
369
+ - If neither `loggerProvider` nor `exporter` is supplied and the SDK/exporter packages are not installed, the transport constructor throws a message naming the packages to install - the loader reports it and skips only this plugin.
370
+ - The plugin shuts the provider down on `close()` only when it created the provider itself; a caller-supplied `loggerProvider` is left alone.
371
+
264
372
  ### Example Logger Configuration
265
373
  Here is an example configuration for NightTimeLogger using multiple plugins:
266
374
 
@@ -272,6 +380,7 @@ let config = {
272
380
  plugins: [
273
381
  {
274
382
  name: 'Discord',
383
+ enabled: true,
275
384
  config: {
276
385
  webhookUrl: process.env.DISCORD_WEBHOOK_URL,
277
386
  avatarUrl: process.env.DISCORD_AVATAR_URL,
@@ -282,6 +391,7 @@ let config = {
282
391
  },
283
392
  {
284
393
  name: 'Sentry',
394
+ enabled: true,
285
395
  config: {
286
396
  dsn: process.env.SENTRY_DSN,
287
397
  release: process.env.SENTRY_RELEASE,
@@ -291,6 +401,7 @@ let config = {
291
401
  },
292
402
  {
293
403
  name: 'MySQL',
404
+ enabled: true,
294
405
  config: {
295
406
  host: process.env.MYSQL_DB_HOST,
296
407
  port: process.env.MYSQL_DB_PORT || 3306,
@@ -302,9 +413,12 @@ let config = {
302
413
  },
303
414
  {
304
415
  name: 'Jest',
416
+ enabled: true,
417
+ config: {},
305
418
  },
306
419
  {
307
420
  name: 'Syslog',
421
+ enabled: true,
308
422
  config: {
309
423
  host: process.env.SYSLOG_HOST || 'localhost',
310
424
  port: process.env.SYSLOG_PORT || 514,
@@ -315,6 +429,7 @@ let config = {
315
429
  },
316
430
  {
317
431
  name: 'OpenObserve',
432
+ enabled: true,
318
433
  config: {
319
434
  host: process.env.OPENOBSERVE_HOST || 'http://localhost:5080',
320
435
  organization: process.env.OPENOBSERVE_ORGANIZATION || 'default',
@@ -326,10 +441,23 @@ let config = {
326
441
  level: 'info',
327
442
  },
328
443
  },
444
+ {
445
+ name: 'OpenTelemetry',
446
+ enabled: true,
447
+ config: {
448
+ url: process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT || 'http://localhost:4318/v1/logs',
449
+ serviceName: 'MyAppLogger',
450
+ level: 'info',
451
+ },
452
+ },
329
453
  ],
330
454
  };
331
455
  ```
332
456
 
457
+ Every entry above sets `enabled: true` and an object `config` - both are mandatory.
458
+ Plugins whose peer dependency is not installed (`Sentry`, `MySQL`, `Postgres`,
459
+ `OpenTelemetry`) are reported and skipped without affecting the rest of the list.
460
+
333
461
  ### Conclusion
334
462
  The NightTimeLogger is highly customizable and supports various plugins for different logging requirements. By leveraging these plugins, you can easily integrate logging into your existing infrastructure, whether it be storing logs in databases, sending them to monitoring tools like Sentry, or receiving alerts directly in Discord.
335
463
 
@@ -4,8 +4,7 @@
4
4
  */
5
5
 
6
6
  const Transport = require('winston-transport');
7
- const https = require('https');
8
- const { URL } = require('url');
7
+ const { HttpDelivery } = require('./lib/httpDelivery');
9
8
 
10
9
  const colors = require('../lib/colors');
11
10
  const levels = require('../lib/levels');
@@ -17,6 +16,7 @@ class DiscordTransport extends Transport {
17
16
  this.name = 'Discord Webhook Transport for NTLogger';
18
17
 
19
18
  this.webhookUrl = opts.webhookUrl;
19
+ this.delivery = new HttpDelivery(opts.webhookUrl, opts);
20
20
  this.username = opts.username || 'NTLogger';
21
21
  this.avatarUrl = opts.avatarUrl || null;
22
22
  this.strict = opts.strict || false;
@@ -38,10 +38,6 @@ class DiscordTransport extends Transport {
38
38
  }
39
39
 
40
40
  async log(info, callback) {
41
- setImmediate(() => {
42
- this.emit('logged', info);
43
- });
44
-
45
41
  const { level, message, ...meta } = info;
46
42
 
47
43
  // Check if the log level matches the configured level (strict mode) or is at or below the configured level
@@ -57,52 +53,38 @@ class DiscordTransport extends Transport {
57
53
  }
58
54
  }
59
55
 
60
- const payload = JSON.stringify({
61
- username: this.username,
62
- avatar_url: this.avatarUrl,
63
- embeds: [
64
- {
65
- title: `Log Level: ${level.toUpperCase()}`,
66
- description: message,
67
- color: this.levelColors[level] || 0x000000, // Default to black if the level is unknown
68
- fields: Object.keys(meta).map(key => ({
69
- name: key,
70
- value: typeof meta[key] === 'string' ? meta[key] : JSON.stringify(meta[key], null, 2),
71
- inline: false,
72
- })),
73
- timestamp: new Date().toISOString(),
74
- }
75
- ]
76
- });
77
-
78
- const webhookUrl = new URL(this.webhookUrl);
79
-
80
- const options = {
81
- hostname: webhookUrl.hostname,
82
- path: webhookUrl.pathname + webhookUrl.search,
83
- method: 'POST',
84
- headers: {
85
- 'Content-Type': 'application/json',
86
- 'Content-Length': Buffer.byteLength(payload),
87
- },
88
- };
89
-
90
- const req = https.request(options, (res) => {
91
- res.on('data', (chunk) => {
92
- console.log(`Response from Discord: ${chunk}`);
93
- });
94
- res.on('end', () => {
95
- callback();
56
+ try {
57
+ const payload = JSON.stringify({
58
+ username: this.username,
59
+ avatar_url: this.avatarUrl,
60
+ embeds: [
61
+ {
62
+ title: `Log Level: ${level.toUpperCase()}`,
63
+ description: message,
64
+ color: this.levelColors[level] || 0x000000, // Default to black if the level is unknown
65
+ fields: Object.keys(meta).map(key => ({
66
+ name: key,
67
+ value: typeof meta[key] === 'string' ? meta[key] : JSON.stringify(meta[key], null, 2),
68
+ inline: false,
69
+ })),
70
+ timestamp: new Date().toISOString(),
71
+ }
72
+ ]
96
73
  });
97
- });
98
74
 
99
- req.on('error', (e) => {
100
- console.error(`Failed to send log to Discord: ${e.message}`);
101
- });
75
+ if (this.closed) throw new Error('HTTP transport is closed');
76
+ await this.delivery.send(payload);
77
+ this.emit('logged', info);
78
+ callback();
79
+ } catch (error) { callback(error); }
102
80
 
103
- req.write(payload);
104
- req.end();
105
81
  }
82
+ flush() { return this.delivery.flush(); }
83
+ close() {
84
+ this.closed = true;
85
+ return this.delivery.flush();
86
+ }
87
+
106
88
  }
107
89
 
108
90
  module.exports = {
package/plugins/index.js CHANGED
@@ -3,19 +3,28 @@
3
3
  * @description Initializes any plugins required by the application.
4
4
  */
5
5
 
6
- // Available plugins
6
+ // Available plugins.
7
+ //
8
+ // Each entry is a *thunk*, not a module: the `require()` only runs when the
9
+ // plugin is actually requested by a config entry. Several plugins depend on
10
+ // optional peer dependencies (`@sentry/node`, `mysql2`, `pg`, the
11
+ // OpenTelemetry packages) that consumers are not required to install, so
12
+ // resolving every module at load time would break `require('ntlogger')` for
13
+ // everyone who does not use those backends. Resolution happens inside the
14
+ // per-plugin try/catch in initPlugins(), which means a missing optional
15
+ // dependency disables only that one plugin.
7
16
  const plugins = {
8
- Sentry : require('./sentry'),
9
- MySQL : require('./mysql'),
10
- Postgres : require('./postgres'),
11
- Jest : require('./jest'),
12
- Syslog : require('./syslog'),
13
- Discord : require('./discord'),
14
- Teams : require('./teams'),
15
- OpenObserve : require('./openobserve'),
16
- OpenTelemetry : require('./otel'),
17
+ Sentry : () => require('./sentry'),
18
+ MySQL : () => require('./mysql'),
19
+ Postgres : () => require('./postgres'),
20
+ Jest : () => require('./jest'),
21
+ Syslog : () => require('./syslog'),
22
+ Discord : () => require('./discord'),
23
+ Teams : () => require('./teams'),
24
+ OpenObserve : () => require('./openobserve'),
25
+ OpenTelemetry : () => require('./otel'),
17
26
 
18
- // WIP SMSMail : require('./smsMail'),
27
+ // WIP SMSMail : () => require('./smsMail'),
19
28
  };
20
29
 
21
30
  // ------------------------------ DO NOT MODIFY BELOW THIS LINE ------------------------------ //
@@ -26,9 +35,15 @@ function checkPluginAvailability(pluginName) {
26
35
  }
27
36
  }
28
37
 
38
+ /**
39
+ * Lazily resolves a plugin module and returns its transport class.
40
+ * Any error thrown here (including a missing optional peer dependency)
41
+ * is reported and skipped by the caller rather than crashing the logger.
42
+ */
29
43
  function getPluginTransport(pluginName) {
30
44
  checkPluginAvailability(pluginName);
31
- return plugins[pluginName].transport;
45
+ const pluginModule = plugins[pluginName]();
46
+ return pluginModule && pluginModule.transport;
32
47
  }
33
48
 
34
49
  /**
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const https = require('https');
5
+
6
+ class HttpDelivery {
7
+ constructor(url, opts = {}) {
8
+ this.url = new URL(url);
9
+ if (!['http:', 'https:'].includes(this.url.protocol)) throw new Error('Expected an HTTP(S) URL');
10
+ this.timeout = opts.timeout ?? 5000;
11
+ this.maxRetries = opts.maxRetries ?? 3;
12
+ this.retryDelay = opts.retryDelay ?? 1000;
13
+ this.maxPending = opts.maxPending ?? 100;
14
+ for (const [name, value] of Object.entries({ timeout: this.timeout, maxPending: this.maxPending })) {
15
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`Invalid ${name}`);
16
+ }
17
+ if (!Number.isInteger(this.maxRetries) || this.maxRetries < 0 || !Number.isFinite(this.retryDelay) || this.retryDelay < 0) {
18
+ throw new Error('Invalid retry configuration');
19
+ }
20
+ this.pending = new Set();
21
+ this.failure = null;
22
+ }
23
+
24
+ send(payload, headers = {}) {
25
+ if (this.pending.size >= this.maxPending) {
26
+ const error = new Error('HTTP delivery queue is full');
27
+ this.failure = error;
28
+ return Promise.reject(error);
29
+ }
30
+ const operation = this.deliver(payload, headers);
31
+ this.pending.add(operation);
32
+ operation.then(() => this.pending.delete(operation), error => {
33
+ this.failure = error;
34
+ this.pending.delete(operation);
35
+ });
36
+ return operation;
37
+ }
38
+
39
+ async deliver(payload, headers) {
40
+ for (let attempt = 0; ; attempt++) {
41
+ try { return await this.request(payload, headers); }
42
+ catch (error) {
43
+ if (error.retryable === false || attempt >= this.maxRetries) throw error;
44
+ const wait = Math.min(error.retryAfter ?? this.retryDelay * 2 ** attempt, this.timeout);
45
+ await new Promise(resolve => setTimeout(resolve, wait));
46
+ }
47
+ }
48
+ }
49
+
50
+ request(payload, headers) {
51
+ return new Promise((resolve, reject) => {
52
+ const protocol = this.url.protocol === 'https:' ? https : http;
53
+ let timer;
54
+ const req = protocol.request(this.url, {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), ...headers },
57
+ }, res => {
58
+ let body = '';
59
+ res.setEncoding('utf8');
60
+ res.on('data', chunk => {
61
+ body += chunk;
62
+ if (body.length > 1048576) req.destroy(new Error('HTTP response exceeds 1 MB'));
63
+ });
64
+ res.on('aborted', () => req.destroy(new Error('HTTP response aborted')));
65
+ res.on('error', error => req.destroy(error));
66
+ res.on('end', () => {
67
+ clearTimeout(timer);
68
+ if (res.statusCode >= 200 && res.statusCode < 300 && !body.includes('Microsoft Teams endpoint returned HTTP error 429')) {
69
+ resolve();
70
+ } else {
71
+ const error = new Error(`HTTP log delivery failed (${res.statusCode})`);
72
+ error.retryable = res.statusCode === 429 || res.statusCode >= 500 || body.includes('HTTP error 429');
73
+ const retryAfter = res.headers['retry-after'];
74
+ if (retryAfter) {
75
+ const ms = Number.isFinite(Number(retryAfter)) ? Number(retryAfter) * 1000 : Date.parse(retryAfter) - Date.now();
76
+ if (Number.isFinite(ms)) error.retryAfter = Math.max(0, ms);
77
+ }
78
+ reject(error);
79
+ }
80
+ });
81
+ });
82
+ timer = setTimeout(() => req.destroy(new Error('HTTP log delivery timed out')), this.timeout);
83
+ req.on('error', error => { clearTimeout(timer); reject(error); });
84
+ req.end(payload);
85
+ });
86
+ }
87
+
88
+ async flush() {
89
+ await Promise.allSettled([...this.pending]);
90
+ if (this.failure) {
91
+ const error = this.failure;
92
+ this.failure = null;
93
+ throw error;
94
+ }
95
+ }
96
+ }
97
+
98
+ module.exports = { HttpDelivery };
@@ -13,7 +13,7 @@ class SyslogClient {
13
13
  this.port = opts.port || 514;
14
14
  this.protocol = opts.protocol || 'UDP'; // 'UDP', 'TCP', 'TLS'
15
15
  this.rfc = opts.rfc || 'RFC-5424'; // 'RFC-3164', 'RFC-5424'
16
- this.facility = opts.facility || 1; // Default to user-level messages
16
+ this.facility = opts.facility ?? 1; // Default to user-level messages
17
17
  this.appName = opts.appName || 'NTLogger';
18
18
  this.hostname = opts.hostname || require('os').hostname();
19
19
  this.transport = null;
@@ -52,12 +52,13 @@ class SyslogClient {
52
52
  * @param {string} message - The log message.
53
53
  * @param {object} [meta] - Additional metadata for RFC-5424 structured data.
54
54
  */
55
- send(severity, message, meta = {}) {
55
+ send(severity, message, meta = {}, callback = () => {}) {
56
56
  const formattedMessage = this.formatMessage(severity, message, meta);
57
57
  if (this.protocol === 'UDP') {
58
- this.transport.send(Buffer.from(formattedMessage), 0, formattedMessage.length, this.port, this.host);
58
+ const buffer = Buffer.from(formattedMessage);
59
+ this.transport.send(buffer, 0, buffer.length, this.port, this.host, callback);
59
60
  } else {
60
- this.transport.write(formattedMessage + '\n');
61
+ this.transport.write(formattedMessage + '\n', callback);
61
62
  }
62
63
  }
63
64
 
@@ -101,12 +102,21 @@ class SyslogClient {
101
102
  * Closes the transport connection.
102
103
  */
103
104
  close() {
104
- if (this.transport && (this.protocol === 'TCP' || this.protocol === 'TLS')) {
105
- this.transport.end();
106
- } else if (this.protocol === 'UDP') {
107
- this.transport.close();
105
+ if (!this.closePromise) {
106
+ this.closePromise = new Promise((resolve, reject) => {
107
+ try {
108
+ if (this.protocol === 'UDP') this.transport.close(resolve);
109
+ else if (this.transport.destroyed) resolve();
110
+ else this.transport.end(resolve);
111
+ } catch (error) {
112
+ if (error.code === 'ERR_SOCKET_DGRAM_NOT_RUNNING') resolve();
113
+ else reject(error);
114
+ }
115
+ });
108
116
  }
117
+ return this.closePromise;
109
118
  }
119
+
110
120
  }
111
121
 
112
122
  module.exports = SyslogClient;