ntlogger 2.9.1 → 3.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.
@@ -0,0 +1,263 @@
1
+ /**
2
+ * @file /plugins/otel.js
3
+ * @description Sends logs to an OpenTelemetry collector via the OTLP Logs SDK.
4
+ *
5
+ * OpenTelemetry packages are optional peer dependencies. Install them when using this plugin:
6
+ * npm install @opentelemetry/api @opentelemetry/api-logs @opentelemetry/sdk-logs \
7
+ * @opentelemetry/exporter-logs-otlp-http @opentelemetry/resources \
8
+ * @opentelemetry/semantic-conventions
9
+ *
10
+ * For gRPC or protobuf transport, install the corresponding exporter package and
11
+ * pass it via the `exporter` option.
12
+ */
13
+
14
+ const Transport = require('winston-transport');
15
+ const levels = require('../lib/levels');
16
+
17
+ const ANSI_REGEX = /\[[0-9;]*m/g;
18
+
19
+ function stripAnsi(str) {
20
+ if (typeof str !== 'string') return str;
21
+ return str.replace(ANSI_REGEX, '');
22
+ }
23
+
24
+ /**
25
+ * Map ntlogger levels to OpenTelemetry SeverityNumber values.
26
+ * See: https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber
27
+ */
28
+ const SEVERITY_NUMBER = {
29
+ internal: 1, // TRACE
30
+ trace: 1, // TRACE
31
+ debug: 5, // DEBUG
32
+ info: 9, // INFO
33
+ warn: 13, // WARN
34
+ error: 17, // ERROR
35
+ fatal: 21, // FATAL
36
+ };
37
+
38
+ const SEVERITY_TEXT = {
39
+ internal: 'TRACE',
40
+ trace: 'TRACE',
41
+ debug: 'DEBUG',
42
+ info: 'INFO',
43
+ warn: 'WARN',
44
+ error: 'ERROR',
45
+ fatal: 'FATAL',
46
+ };
47
+
48
+ /**
49
+ * Lazily require an OTel package, returning null if it's not installed.
50
+ * Lets the plugin loader skip OTel gracefully when deps are missing.
51
+ */
52
+ function tryRequire(name) {
53
+ try {
54
+ return require(name);
55
+ } catch (err) {
56
+ if (err && err.code === 'MODULE_NOT_FOUND') return null;
57
+ throw err;
58
+ }
59
+ }
60
+
61
+ class OpenTelemetryTransport extends Transport {
62
+ constructor(opts = {}) {
63
+ super(opts);
64
+
65
+ this.name = 'OpenTelemetry Transport for NTLogger';
66
+
67
+ // Level threshold (default: log everything down to 'trace')
68
+ this.level = opts.level || 'trace';
69
+ if (!(this.level in levels)) {
70
+ throw new Error(`Invalid log level: ${this.level}`);
71
+ }
72
+ this.levelPriority = levels[this.level];
73
+
74
+ this.includeTraceContext = opts.includeTraceContext !== false;
75
+ this.stripAnsi = opts.stripAnsi !== false;
76
+
77
+ // Caller-supplied LoggerProvider takes precedence and lets the plugin
78
+ // run without the OTel SDK packages installed (useful for tests).
79
+ if (opts.loggerProvider) {
80
+ this.loggerProvider = opts.loggerProvider;
81
+ this._ownsProvider = false;
82
+ } else {
83
+ const sdkLogs = tryRequire('@opentelemetry/sdk-logs');
84
+ if (!sdkLogs) {
85
+ throw new Error(
86
+ 'OpenTelemetry plugin requires @opentelemetry/sdk-logs (and an exporter) ' +
87
+ 'when no `loggerProvider` is supplied. ' +
88
+ 'Install with: npm install @opentelemetry/sdk-logs @opentelemetry/api-logs @opentelemetry/exporter-logs-otlp-http'
89
+ );
90
+ }
91
+ this.loggerProvider = this._buildLoggerProvider(opts, sdkLogs);
92
+ this._ownsProvider = true;
93
+ }
94
+
95
+ const instrumentationName = opts.instrumentationName || 'ntlogger';
96
+ const instrumentationVersion = opts.instrumentationVersion || undefined;
97
+ this.otelLogger = this.loggerProvider.getLogger(instrumentationName, instrumentationVersion);
98
+
99
+ // Optional @opentelemetry/api for trace context correlation
100
+ if (this.includeTraceContext) {
101
+ const api = tryRequire('@opentelemetry/api');
102
+ this._traceApi = api && api.trace ? api.trace : null;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Build a LoggerProvider from configuration when one isn't supplied.
108
+ */
109
+ _buildLoggerProvider(opts, sdkLogs) {
110
+ const { LoggerProvider, BatchLogRecordProcessor, SimpleLogRecordProcessor } = sdkLogs;
111
+
112
+ let exporter = opts.exporter;
113
+ if (!exporter) {
114
+ const otlpHttp = tryRequire('@opentelemetry/exporter-logs-otlp-http');
115
+ if (!otlpHttp) {
116
+ throw new Error(
117
+ 'OpenTelemetry plugin requires either an `exporter` instance or ' +
118
+ '@opentelemetry/exporter-logs-otlp-http installed. ' +
119
+ 'Install with: npm install @opentelemetry/exporter-logs-otlp-http'
120
+ );
121
+ }
122
+ exporter = new otlpHttp.OTLPLogExporter({
123
+ url: opts.url,
124
+ headers: opts.headers,
125
+ concurrencyLimit: opts.concurrencyLimit,
126
+ timeoutMillis: opts.timeoutMillis,
127
+ });
128
+ }
129
+
130
+ const Processor = opts.useSimpleProcessor ? SimpleLogRecordProcessor : BatchLogRecordProcessor;
131
+ const processor = new Processor({ ...(opts.processorOptions || {}), exporter });
132
+
133
+ let resource;
134
+ const resources = tryRequire('@opentelemetry/resources');
135
+ if (resources && (opts.resource || opts.resourceAttributes || opts.serviceName)) {
136
+ if (opts.resource) {
137
+ resource = opts.resource;
138
+ } else {
139
+ const attrs = { ...(opts.resourceAttributes || {}) };
140
+ if (opts.serviceName) {
141
+ const semconv = tryRequire('@opentelemetry/semantic-conventions');
142
+ const key = (semconv && semconv.ATTR_SERVICE_NAME) || 'service.name';
143
+ attrs[key] = opts.serviceName;
144
+ if (opts.serviceVersion) {
145
+ const vkey = (semconv && semconv.ATTR_SERVICE_VERSION) || 'service.version';
146
+ attrs[vkey] = opts.serviceVersion;
147
+ }
148
+ }
149
+ resource = resources.resourceFromAttributes
150
+ ? resources.resourceFromAttributes(attrs)
151
+ : new resources.Resource(attrs);
152
+ }
153
+ }
154
+
155
+ return new LoggerProvider({
156
+ resource,
157
+ processors: [processor],
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Flatten metadata into OTel-compatible primitive/array attributes.
163
+ * Objects are JSON-stringified since OTel attributes can't be nested.
164
+ */
165
+ _toAttributes(meta) {
166
+ const attrs = {};
167
+ for (const [key, value] of Object.entries(meta)) {
168
+ if (value === null || value === undefined) continue;
169
+ if (key === 'timestamp' || key === 'timeCreated') continue;
170
+
171
+ const t = typeof value;
172
+ if (t === 'string') {
173
+ attrs[key] = this.stripAnsi ? stripAnsi(value) : value;
174
+ } else if (t === 'number' || t === 'boolean') {
175
+ attrs[key] = value;
176
+ } else if (Array.isArray(value)) {
177
+ attrs[key] = value.map(v =>
178
+ typeof v === 'object' && v !== null ? JSON.stringify(v) : v
179
+ );
180
+ } else if (t === 'object') {
181
+ try {
182
+ attrs[key] = JSON.stringify(value);
183
+ } catch {
184
+ attrs[key] = String(value);
185
+ }
186
+ } else {
187
+ attrs[key] = String(value);
188
+ }
189
+ }
190
+ return attrs;
191
+ }
192
+
193
+ log(info, callback) {
194
+ setImmediate(() => {
195
+ this.emit('logged', info);
196
+ });
197
+
198
+ const { level, message, ...meta } = info;
199
+
200
+ if (levels[level] === undefined || levels[level] > this.levelPriority) {
201
+ callback();
202
+ return;
203
+ }
204
+
205
+ const severityNumber = SEVERITY_NUMBER[level] ?? SEVERITY_NUMBER.info;
206
+ const severityText = SEVERITY_TEXT[level] || String(level).toUpperCase();
207
+ const body = this.stripAnsi && typeof message === 'string' ? stripAnsi(message) : message;
208
+
209
+ const record = {
210
+ severityNumber,
211
+ severityText,
212
+ body,
213
+ attributes: this._toAttributes(meta),
214
+ ...(meta.timestamp ? { timestamp: new Date(meta.timestamp) } : {}),
215
+ };
216
+
217
+ if (this._traceApi) {
218
+ const span = this._traceApi.getActiveSpan();
219
+ const ctx = span && span.spanContext && span.spanContext();
220
+ if (ctx && ctx.traceId && ctx.spanId) {
221
+ record.traceId = ctx.traceId;
222
+ record.spanId = ctx.spanId;
223
+ if (typeof ctx.traceFlags === 'number') {
224
+ record.traceFlags = ctx.traceFlags;
225
+ }
226
+ }
227
+ }
228
+
229
+ try {
230
+ this.otelLogger.emit(record);
231
+ } catch (err) {
232
+ callback(err);
233
+ return;
234
+ }
235
+
236
+ callback();
237
+ }
238
+
239
+ /**
240
+ * Force-flush pending log records.
241
+ */
242
+ flush() {
243
+ if (this.loggerProvider && typeof this.loggerProvider.forceFlush === 'function') {
244
+ return this.loggerProvider.forceFlush();
245
+ }
246
+ return Promise.resolve();
247
+ }
248
+
249
+ /**
250
+ * Shut down the LoggerProvider (only if this transport created it).
251
+ */
252
+ async close() {
253
+ if (this._ownsProvider && this.loggerProvider && typeof this.loggerProvider.shutdown === 'function') {
254
+ await this.loggerProvider.shutdown();
255
+ }
256
+ }
257
+ }
258
+
259
+ module.exports = {
260
+ transport: OpenTelemetryTransport,
261
+ SEVERITY_NUMBER,
262
+ SEVERITY_TEXT,
263
+ };
@@ -1,14 +1,36 @@
1
1
  /**
2
2
  * @file /plugins/postgres.js
3
3
  * @description Stores logs in PostgresSQL database.
4
+ *
5
+ * `pg` is an optional peer dependency. Install it when using this plugin:
6
+ * npm install pg
4
7
  */
5
8
 
6
9
  const Transport = require('winston-transport');
7
- const { Pool } = require('pg');
8
10
  const levels = require('../lib/levels');
9
11
 
10
- let connectionPool = null;
11
- let tableInitialized = false;
12
+ /**
13
+ * Requires an optional peer dependency, translating only that package's
14
+ * MODULE_NOT_FOUND into an actionable install message. Any other failure
15
+ * (including a missing module *inside* the package) propagates unchanged.
16
+ * @param {string} moduleId - The module specifier to require.
17
+ * @param {string} packageName - The npm package to install.
18
+ * @returns {*} The resolved module.
19
+ */
20
+ function requirePeer(moduleId, packageName) {
21
+ try {
22
+ return require(moduleId);
23
+ } catch (err) {
24
+ if (err && err.code === 'MODULE_NOT_FOUND' && String(err.message).includes(`'${moduleId}'`)) {
25
+ throw new Error(`ntlogger Postgres plugin requires '${packageName}'. Install it with: npm install ${packageName}`);
26
+ }
27
+ throw err;
28
+ }
29
+ }
30
+
31
+ const { Pool } = requirePeer('pg', 'pg');
32
+
33
+
12
34
 
13
35
  class PostgreSQLTransport extends Transport {
14
36
  constructor(opts = {}) {
@@ -17,6 +39,11 @@ class PostgreSQLTransport extends Transport {
17
39
  this.name = 'PostgreSQL Transport for NTLogger';
18
40
  this.database = opts.database || 'test';
19
41
  this.table = opts.table || 'logs';
42
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(this.table)) {
43
+ throw new Error('Log table must be a simple SQL identifier');
44
+ }
45
+ this.tableInitialized = false;
46
+ this.closed = false;
20
47
 
21
48
  this.logLevel = opts.level ? levels[opts.level] : levels.info;
22
49
 
@@ -26,58 +53,44 @@ class PostgreSQLTransport extends Transport {
26
53
  user: opts.user || 'postgres',
27
54
  password: opts.password || '',
28
55
  database: this.database,
56
+ ssl: opts.ssl,
29
57
  max: opts.connectionLimit || 10,
30
58
  idleTimeoutMillis: opts.idleTimeout || 60000,
31
59
  connectionTimeoutMillis: opts.connectionTimeout || 2000,
32
60
  };
33
61
 
34
- this.init();
62
+ this.pool = new Pool(this.poolConfig);
63
+ this.pool.on('error', error => { this.lastError = error; });
64
+ this.ready = this.checkLogTable();
65
+ this.ready.catch(error => { this.lastError = error; });
35
66
  }
36
67
 
37
- async init() {
38
- try {
39
- if (!connectionPool) {
40
- connectionPool = new Pool(this.poolConfig);
41
- }
42
- this.pool = connectionPool;
43
- await this.checkLogTable();
44
- } catch (err) {
45
- console.error(`Failed to initialize ${this.name}:`, err);
46
- }
47
- }
68
+ async init() { return this.ready; }
48
69
 
49
70
  async log(info, callback) {
50
- setImmediate(() => {
51
- this.emit('logged', info);
52
- });
53
-
54
- if (!tableInitialized) {
55
- await this.checkLogTable();
56
- }
57
-
58
- const { level, message, ...meta } = info;
59
-
60
- if (levels[level] > this.logLevel) {
61
- callback();
62
- return;
63
- }
64
-
65
- const metaString = JSON.stringify(meta);
66
- const timestamp = this.formatTimestamp(meta.timeCreated);
67
-
71
+ if (levels[info.level] > this.logLevel) { callback(); return; }
68
72
  try {
73
+ if (this.closed) throw new Error('Database transport is closed');
74
+ await this.ready;
75
+ const { level, message, ...meta } = info;
76
+ const timestamp = this.formatTimestamp(meta.timestamp);
69
77
  await this.pool.query(
70
78
  `INSERT INTO ${this.table} (level, message, meta, timestamp) VALUES ($1, $2, $3, $4)`,
71
- [level, message, metaString, timestamp]
79
+ [level, message, JSON.stringify(meta), timestamp]
72
80
  );
73
- } catch (err) {
74
- console.error(`Failed to log message to ${this.name}:`, err);
81
+ this.emit('logged', info);
82
+ callback();
83
+ } catch (error) {
84
+ this.lastError = error;
85
+ callback(error);
75
86
  }
76
-
77
- callback();
78
87
  }
79
88
 
80
89
  formatTimestamp(timeCreated) {
90
+ if (timeCreated && /^\d{4}-/.test(timeCreated)) {
91
+ const date = new Date(timeCreated);
92
+ return date.toISOString();
93
+ }
81
94
  if (!timeCreated) {
82
95
  return new Date().toISOString().slice(0, 19).replace('T', ' ');
83
96
  }
@@ -96,43 +109,32 @@ class PostgreSQLTransport extends Transport {
96
109
  return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${hours}:${minutes}:${seconds}`;
97
110
  }
98
111
 
99
- async checkLogTable() {
100
- if (tableInitialized) return;
101
-
102
- try {
103
- const result = await this.pool.query(
104
- `SELECT to_regclass('${this.table}') as tablename`
105
- );
106
-
107
- if (!result.rows[0].tablename) {
108
- await this.pool.query(
109
- `CREATE TABLE IF NOT EXISTS ${this.table} (
110
- id SERIAL PRIMARY KEY,
111
- level VARCHAR(255) NOT NULL,
112
- message TEXT NOT NULL,
113
- meta JSONB,
114
- timestamp TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
115
- )`
116
- );
117
- }
118
-
119
- tableInitialized = true;
120
- } catch (err) {
121
- console.error(`Failed to check or create table ${this.table}:`, err);
112
+ checkLogTable() {
113
+ if (!this.tableReady) {
114
+ this.tableReady = this.pool.query(`CREATE TABLE IF NOT EXISTS ${this.table} (id SERIAL PRIMARY KEY, level VARCHAR(255) NOT NULL, message TEXT NOT NULL, meta JSONB, timestamp TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP)`)
115
+ .then(() => { this.tableInitialized = true; })
116
+ .catch(error => { this.tableReady = null; throw error; });
122
117
  }
118
+ return this.tableReady;
123
119
  }
124
120
 
125
121
  isActive() {
126
- return this.pool && !this.pool.ended;
122
+ return this.pool && !this.closed;
123
+ }
124
+
125
+ async flush() {
126
+ await this.ready;
127
+ if (this.lastError) throw this.lastError;
127
128
  }
128
129
 
129
- async close() {
130
- if (connectionPool) {
131
- await connectionPool.end();
132
- connectionPool = null;
133
- console.log('Closed PostgreSQL connection pool.');
130
+ close() {
131
+ if (!this.closePromise) {
132
+ this.closed = true;
133
+ this.closePromise = this.ready.catch(() => {}).then(() => this.pool.end());
134
134
  }
135
+ return this.closePromise;
135
136
  }
137
+
136
138
  }
137
139
 
138
140
  module.exports = {
package/plugins/sentry.js CHANGED
@@ -1,10 +1,33 @@
1
1
  /**
2
2
  * @file /plugins/sentry.js
3
3
  * @description Initializes the Sentry SDK for error tracking and defines a custom Winston transport.
4
+ *
5
+ * `@sentry/node` is an optional peer dependency. Install it when using this plugin:
6
+ * npm install @sentry/node
4
7
  */
5
8
 
6
9
  const Transport = require('winston-transport');
7
- const Sentry = require("@sentry/node");
10
+
11
+ /**
12
+ * Requires an optional peer dependency, translating only that package's
13
+ * MODULE_NOT_FOUND into an actionable install message. Any other failure
14
+ * (including a missing module *inside* the package) propagates unchanged.
15
+ * @param {string} moduleId - The module specifier to require.
16
+ * @param {string} packageName - The npm package to install.
17
+ * @returns {*} The resolved module.
18
+ */
19
+ function requirePeer(moduleId, packageName) {
20
+ try {
21
+ return require(moduleId);
22
+ } catch (err) {
23
+ if (err && err.code === 'MODULE_NOT_FOUND' && String(err.message).includes(`'${moduleId}'`)) {
24
+ throw new Error(`ntlogger Sentry plugin requires '${packageName}'. Install it with: npm install ${packageName}`);
25
+ }
26
+ throw err;
27
+ }
28
+ }
29
+
30
+ const Sentry = requirePeer('@sentry/node', '@sentry/node');
8
31
 
9
32
  /**
10
33
  * Custom Winston transport for sending logs to Sentry.
@@ -14,6 +37,7 @@ class SentryTransport extends Transport {
14
37
  super(opts);
15
38
 
16
39
  this.name = 'Sentry Transport for NTLogger';
40
+ this.flushTimeout = opts.flushTimeout ?? 5000;
17
41
  this.init(opts);
18
42
  }
19
43
 
@@ -37,13 +61,22 @@ class SentryTransport extends Transport {
37
61
  const { level, message, ...meta } = info;
38
62
 
39
63
  if (level === 'error') {
40
- Sentry.captureException(new Error(message), { extra: meta });
64
+ const error = new Error(message);
65
+ if (meta.stack) error.stack = meta.stack;
66
+ Sentry.captureException(error, { extra: meta });
41
67
  } else {
42
- Sentry.captureMessage(message, { level, extra: meta });
68
+ Sentry.captureMessage(message, { level: ({ warn: 'warning', trace: 'debug', internal: 'debug' })[level] || level, extra: meta });
43
69
  }
44
70
 
45
71
  callback();
46
72
  }
73
+ async flush() {
74
+ if (!await Sentry.flush(this.flushTimeout)) throw new Error('Sentry flush timed out');
75
+ }
76
+
77
+ // Flush without disabling the SDK shared with the host application.
78
+ close() { return this.flush(); }
79
+
47
80
  }
48
81
 
49
82
  module.exports = {
package/plugins/syslog.js CHANGED
@@ -16,12 +16,12 @@ class SyslogTransport extends Transport {
16
16
  port: opts.port || 514,
17
17
  protocol: opts.protocol || 'UDP', // Options: 'UDP', 'TCP', 'TLS'
18
18
  rfc: opts.rfc || 'RFC-5424', // Options: 'RFC-3164', 'RFC-5424'
19
- facility: opts.facility || 1,
19
+ facility: opts.facility ?? 1,
20
20
  appName: opts.appName || 'NTLogger',
21
21
  hostname: opts.hostname || require('os').hostname(),
22
22
  });
23
23
 
24
- this.levels = {
24
+ this.severities = {
25
25
  internal: 7, // Debug level for internal logs
26
26
  trace: 7, // Debug
27
27
  debug: 7, // Debug
@@ -30,7 +30,7 @@ class SyslogTransport extends Transport {
30
30
  error: 3, // Error
31
31
  fatal: 2, // Critical
32
32
  };
33
- this.logLevel = opts.level ? this.levels[opts.level] : this.levels.info;
33
+ this.logLevel = opts.level ? this.severities[opts.level] : this.severities.info;
34
34
 
35
35
  if (this.client.protocol === 'TCP' || this.client.protocol === 'TLS') {
36
36
  this.client.connect().catch(err => {
@@ -51,14 +51,12 @@ class SyslogTransport extends Transport {
51
51
 
52
52
  const { level, message, ...meta } = info;
53
53
 
54
- if (this.levels[level] > this.logLevel) {
54
+ if (this.severities[level] > this.logLevel) {
55
55
  callback();
56
56
  return;
57
57
  }
58
58
 
59
- this.client.send(this.levels[level], message, meta);
60
-
61
- callback();
59
+ this.client.send(this.severities[level], message, meta, callback);
62
60
  }
63
61
 
64
62
  /**
@@ -74,8 +72,7 @@ class SyslogTransport extends Transport {
74
72
  * @returns {Promise<void>}
75
73
  */
76
74
  async close() {
77
- this.client.close();
78
- console.log('Closed Syslog client connection.');
75
+ await this.client.close();
79
76
  }
80
77
  }
81
78