ntlogger 3.0.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/index.d.ts CHANGED
@@ -53,6 +53,11 @@ declare namespace logger {
53
53
  * Logger configuration options
54
54
  */
55
55
  export interface LoggerConfig {
56
+ /** Maximum in-flight callback promises; overflow drops newest callback, not the primary log. */
57
+ onLogMaxPending?: number;
58
+ onLog?: (record: Record<string, any>) => void | Promise<void>;
59
+ otel?: boolean | { loggerProvider?: { getLogger(name: string): { emit(record: any): void }; forceFlush?(): Promise<void> }; name?: string };
60
+ redact?: false | { extraKeys?: string[]; patterns?: boolean; replacement?: string; maxDepth?: number; maxStringLength?: number };
56
61
  /** Minimum log level to be logged */
57
62
  level?: LogLevel;
58
63
  /** Enable console logging (default: true) */
@@ -95,6 +100,9 @@ declare namespace logger {
95
100
  * Statistics object returned by getStats()
96
101
  */
97
102
  export interface LoggerStats {
103
+ levels: Record<string, number>;
104
+ hookErrors: number;
105
+ onLog: { pending: number; dropped: number; limit: number };
98
106
  sampling: {
99
107
  total: Record<string, number>;
100
108
  sampled: Record<string, number>;
@@ -199,6 +207,7 @@ declare namespace logger {
199
207
  * @returns Statistics object
200
208
  */
201
209
  getStats(): LoggerStats;
210
+ resetStats(): void;
202
211
 
203
212
  /**
204
213
  * Flush all pending logs (returns Promise)
@@ -227,6 +236,7 @@ declare function logger(location?: string, config?: logger.LoggerConfig): logger
227
236
 
228
237
  declare namespace logger {
229
238
  /** Opt in to process-wide signal handlers; returns an unregister function. */
239
+ function withSecretValues<T>(values: string[], callback: () => T): T;
230
240
  function setupSignalHandlers(options?: { timeout?: number }): () => void;
231
241
  }
232
242
 
package/index.js CHANGED
@@ -114,4 +114,21 @@
114
114
  * Initial release
115
115
  */
116
116
 
117
- module.exports = require('./lib/logger');
117
+ // Only the Winston entry point resolves these peers. Pino consumers never load it.
118
+ for (const peer of ['winston', 'winston-transport']) {
119
+ try {
120
+ require.resolve(peer);
121
+ } catch (cause) {
122
+ if (cause.code !== 'MODULE_NOT_FOUND') throw cause;
123
+ const error = new Error(
124
+ `ntlogger's Winston backend requires ${peer}. Run: npm install winston winston-transport. ` +
125
+ `For Pino, import 'ntlogger/pino' instead.`,
126
+ { cause },
127
+ );
128
+ error.code = 'MODULE_NOT_FOUND';
129
+ throw error;
130
+ }
131
+ }
132
+
133
+ module.exports = require('./lib/logger');
134
+ module.exports.withSecretValues = require('./lib/secretScope').withSecretValues;
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+ const { AsyncLocalStorage } = require('node:async_hooks');
3
+ const { withTimeout } = require('./lifecycle');
4
+
5
+ // Counts represent serialized records, not confirmed delivery to any destination.
6
+ function createLogObserver({ onLog, otel, onLogMaxPending = 100 } = {}) {
7
+ if (onLog !== undefined && typeof onLog !== 'function') throw new TypeError('onLog must be a function');
8
+ if (!Number.isSafeInteger(onLogMaxPending) || onLogMaxPending < 0) throw new TypeError('onLogMaxPending must be a nonnegative safe integer');
9
+ const scope = new AsyncLocalStorage();
10
+ const pending = new Set();
11
+ const counts = Object.fromEntries(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'internal'].map(k => [k, 0]));
12
+ let hookErrors = 0;
13
+ let dropped = 0;
14
+ const bridge = otel ? require('./pinoOtel').createOtelBridge(otel) : null;
15
+ const levelNames = { 10: 'trace', 20: 'debug', 30: 'info', 40: 'warn', 50: 'error', 60: 'fatal' };
16
+ function failure() { hookErrors++; }
17
+ function observe(record) {
18
+ const level = typeof record.level === 'number' ? levelNames[record.level] : record.level;
19
+ if (Object.hasOwn(counts, level)) counts[level]++;
20
+ // Suppress observers for logs made by an observer, including async descendants.
21
+ if (scope.getStore()) return;
22
+ scope.run(true, () => {
23
+ if (bridge) {
24
+ try { bridge.emit(record); } catch (_) { failure(); }
25
+ }
26
+ if (onLog) {
27
+ // In-flight promises cannot be cancelled: drop the newest callback only.
28
+ if (pending.size >= onLogMaxPending) { dropped++; return; }
29
+ try {
30
+ // Mutation of the callback record cannot change output or OTel data.
31
+ const result = onLog(JSON.parse(JSON.stringify(record)));
32
+ if (result && typeof result.then === 'function') {
33
+ const task = Promise.resolve(result).catch(failure);
34
+ pending.add(task);
35
+ task.finally(() => pending.delete(task));
36
+ }
37
+ } catch (_) { failure(); }
38
+ }
39
+ });
40
+ }
41
+ return {
42
+ context: () => bridge ? bridge.context() : {},
43
+ observe,
44
+ streamWrite(line) { observe(JSON.parse(line)); return line; },
45
+ stats: () => ({ levels: { ...counts }, hookErrors, onLog: { pending: pending.size, dropped, limit: onLogMaxPending } }),
46
+ reset() { for (const key of Object.keys(counts)) counts[key] = 0; hookErrors = 0; dropped = 0; },
47
+ async flush(timeout = 5000) {
48
+ await withTimeout((async () => {
49
+ while (pending.size) await Promise.all([...pending]);
50
+ if (bridge) await bridge.flush();
51
+ })(), timeout, 'Log observers flush');
52
+ },
53
+ };
54
+ }
55
+ module.exports = { createLogObserver };
package/lib/logger.js CHANGED
@@ -344,6 +344,8 @@ const createLoggerInstance = (location = "Unknown", config = {}, transports, par
344
344
  statsInterval = 0,
345
345
  } = config;
346
346
 
347
+ const observer = require('./logObserver').createLogObserver(config);
348
+ const redactor = config.redact === false ? null : require('./secretRedaction').createRedactor(config.redact);
347
349
  let logTransport = [].filter(Boolean);
348
350
 
349
351
  if (console) {
@@ -376,6 +378,13 @@ const createLoggerInstance = (location = "Unknown", config = {}, transports, par
376
378
  const logger = winston.createLogger({
377
379
  level: level,
378
380
  levels: customSettings.levels,
381
+ format: winston.format(info => {
382
+ const safe = redactor ? redactor.redactObject(info) : { ...info };
383
+ Object.assign(safe, observer.context());
384
+ Object.assign(info, safe);
385
+ observer.observe(safe);
386
+ return info;
387
+ })(),
379
388
  transports: logTransport,
380
389
  defaultMeta: {
381
390
  location,
@@ -469,10 +478,17 @@ const createLoggerInstance = (location = "Unknown", config = {}, transports, par
469
478
  sampling: sampler ? sampler.getStats() : null,
470
479
  deduplication: deduplicator ? deduplicator.getStats() : null,
471
480
  performance: perfTracker ? perfTracker.getStats() : null,
481
+ ...observer.stats(),
472
482
  };
473
483
  return stats;
474
484
  };
475
485
 
486
+ logger.resetStats = () => {
487
+ sampler?.resetStats();
488
+ deduplicator?.resetStats();
489
+ observer.reset();
490
+ };
491
+
476
492
  const timeout = config.shutdownTimeout ?? 30000;
477
493
  if (!Number.isFinite(timeout) || timeout <= 0) throw new Error('Invalid shutdownTimeout');
478
494
  let closePromise;
@@ -480,6 +496,7 @@ const createLoggerInstance = (location = "Unknown", config = {}, transports, par
480
496
  logger.flush = () => withTimeout((async () => {
481
497
  await drain(logger, state, timeout);
482
498
  await Promise.all(logger.transports.map(t => t.flush?.()));
499
+ await observer.flush(timeout);
483
500
  await drain(logger, state, timeout);
484
501
  })(), timeout, 'Logger flush');
485
502
 
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ // No SDK/provider is created or registered here. Export through the application's
4
+ // existing logs provider in the calling thread, where active context is available.
5
+ function createOtelBridge(options) {
6
+ const opts = options === true ? {} : options;
7
+ const api = require('@opentelemetry/api');
8
+ const provider = opts.loggerProvider || require('@opentelemetry/api-logs').logs.getLoggerProvider();
9
+ const logger = provider.getLogger(opts.name || 'ntlogger');
10
+ const severity = { 10: 1, 20: 5, 30: 9, 40: 13, 50: 17, 60: 21,
11
+ trace: 1, debug: 5, info: 9, warn: 13, error: 17, fatal: 21, internal: 1 };
12
+ const names = { 1: 'TRACE', 5: 'DEBUG', 9: 'INFO', 13: 'WARN', 17: 'ERROR', 21: 'FATAL' };
13
+ return {
14
+ context() {
15
+ const span = api.trace.getActiveSpan();
16
+ const ctx = span && span.spanContext();
17
+ return ctx && api.isSpanContextValid(ctx)
18
+ ? { traceId: ctx.traceId, spanId: ctx.spanId, traceFlags: ctx.traceFlags }
19
+ : {};
20
+ },
21
+ emit(record) {
22
+ const attributes = {};
23
+ for (const [key, value] of Object.entries(record)) {
24
+ if (['level', 'msg', 'message', 'time', 'timestamp'].includes(key) || value == null) continue;
25
+ attributes[key] = typeof value === 'object' ? JSON.stringify(value) : value;
26
+ }
27
+ if (record.err) {
28
+ if (record.err.type) attributes['exception.type'] = record.err.type;
29
+ if (record.err.message) attributes['exception.message'] = record.err.message;
30
+ if (record.err.stack) attributes['exception.stacktrace'] = record.err.stack;
31
+ }
32
+ const severityNumber = severity[record.level] || 9;
33
+ logger.emit({
34
+ context: api.context.active(),
35
+ timestamp: new Date(record.time ?? record.timestamp ?? Date.now()),
36
+ severityNumber, severityText: names[severityNumber],
37
+ body: record.msg ?? record.message ?? '', attributes,
38
+ });
39
+ },
40
+ flush: () => typeof provider.forceFlush === 'function' ? provider.forceFlush() : Promise.resolve(),
41
+ };
42
+ }
43
+ module.exports = { createOtelBridge };
@@ -87,6 +87,10 @@ const DEFAULT_KEYS = Object.freeze([
87
87
  'webhookurl',
88
88
  'snmpcommunity',
89
89
  'community_string',
90
+ 'community',
91
+ 'authPassword',
92
+ 'privPassword',
93
+ 'passphrase',
90
94
  ]);
91
95
 
92
96
  /*
@@ -145,6 +149,9 @@ const KV_SECRET = /(?<pre>\b(?:api[_-]?key|apikey|access[_-]?key|access[_-]?toke
145
149
  */
146
150
  const GENERIC_KV = /(?<pre>\b(?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*)(?!["'[])\S+/gi;
147
151
 
152
+ /** Scanner credentials in free text, including short and quoted values. */
153
+ const SCANNER_SECRET = /(?<pre>["']?\b(?:snmp[_-]?community|community(?:[_-]?string)?|auth[_-]?password|priv[_-]?password|passphrase)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*(?:"|$)|'(?:\\.|[^'\\])*(?:'|$)|[^\s,;}]+)/gi;
154
+
148
155
  /** Default pattern set, in application order. */
149
156
  const DEFAULT_PATTERNS = Object.freeze([
150
157
  PEM_PRIVATE_KEY,
@@ -156,6 +163,7 @@ const DEFAULT_PATTERNS = Object.freeze([
156
163
  BEARER_TOKEN,
157
164
  BASIC_AUTH,
158
165
  DISCORD_WEBHOOK,
166
+ SCANNER_SECRET,
159
167
  KV_SECRET,
160
168
  GENERIC_KV,
161
169
  URL_CREDENTIALS,
@@ -177,6 +185,7 @@ const PATTERN_HINTS = new Map([
177
185
  [BEARER_TOKEN, 'bearer'],
178
186
  [BASIC_AUTH, 'basic'],
179
187
  [DISCORD_WEBHOOK, 'discord'],
188
+ [SCANNER_SECRET, 'community|auth[_-]?password|priv[_-]?password|passphrase'],
180
189
  [KV_SECRET, 'key|secret|passw|pwd|token'],
181
190
  [GENERIC_KV, 'key|secret|passw|token'],
182
191
  [URL_CREDENTIALS, '://'],
@@ -384,7 +393,7 @@ function createRedactor(options) {
384
393
  return value;
385
394
  }
386
395
  try {
387
- let text = value;
396
+ let text = require('./secretScope').redactSecretValues(value, replacement);
388
397
  let truncated = false;
389
398
  if (text.length > maxStringLength) {
390
399
  text = text.slice(0, maxStringLength);
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+ const { AsyncLocalStorage } = require('node:async_hooks');
3
+ const scope = new AsyncLocalStorage();
4
+
5
+ function withSecretValues(values, callback) {
6
+ if (!Array.isArray(values) || values.some(v => typeof v !== 'string' || !v)) {
7
+ throw new TypeError('Secret values must be nonempty strings');
8
+ }
9
+ const secrets = [...new Set([...(scope.getStore() || []), ...values])];
10
+ if (secrets.length > 128 || secrets.some(v => v.length > 65536)) {
11
+ throw new RangeError('Secret scope exceeds 128 values or 65536 characters per value');
12
+ }
13
+ return scope.run(secrets, callback);
14
+ }
15
+ function redactSecretValues(text, replacement) {
16
+ const secrets = scope.getStore();
17
+ if (!secrets?.length) return text;
18
+ const variants = [...new Set(secrets.flatMap(value => [value, JSON.stringify(value).slice(1, -1)]))]
19
+ .sort((a, b) => b.length - a.length);
20
+ // A single pass avoids redacting the replacement itself or missing overlaps.
21
+ const pattern = new RegExp([replacement, ...variants].map(value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g');
22
+ return text.replace(pattern, () => replacement);
23
+ }
24
+ module.exports = { withSecretValues, redactSecretValues, hasSecretValues: () => Boolean(scope.getStore()?.length) };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ntlogger",
3
- "version": "3.0.0",
4
- "description": "A Custom Ready-To-Go Logging Wrapper Built on Winston",
3
+ "version": "4.0.0",
4
+ "description": "Structured logging with Winston and Pino backends",
5
5
  "keywords": [
6
6
  "custom logger",
7
7
  "discord compatible",
@@ -38,7 +38,13 @@
38
38
  "types": "./pino.d.ts",
39
39
  "default": "./pino.js"
40
40
  },
41
- "./package.json": "./package.json"
41
+ "./package.json": "./package.json",
42
+ "./eslint": {
43
+ "types": "./eslint.d.ts",
44
+ "default": "./eslint.js"
45
+ },
46
+ "./contract/redaction.json": "./contract/redaction.json",
47
+ "./contract/conformance.json": "./contract/conformance.json"
42
48
  },
43
49
  "types": "./index.d.ts",
44
50
  "files": [
@@ -48,31 +54,37 @@
48
54
  "pino.d.ts",
49
55
  "lib/*.js",
50
56
  "plugins/**/*",
51
- "transports/"
57
+ "transports/",
58
+ "eslint.js",
59
+ "eslint.d.ts",
60
+ "eslint-rules/",
61
+ "contract/"
52
62
  ],
53
63
  "scripts": {
54
64
  "test": "jest",
55
65
  "test:coverage": "jest --runInBand --coverage",
56
- "test:unit": "jest --runInBand --testPathIgnorePatterns=tests/plugins/mysql.test.js tests/plugins/postgres.test.js"
57
- },
58
- "dependencies": {
59
- "winston": "^3.19.0",
60
- "winston-transport": "^4.9.0"
66
+ "test:unit": "jest --runInBand --testPathIgnorePatterns=tests/plugins/mysql.test.js tests/plugins/postgres.test.js",
67
+ "test:package": "node scripts/test-package.cjs",
68
+ "test:python": "python3 -W error -m unittest discover -s python/tests",
69
+ "test:contract": "node scripts/sync-python-contract.cjs --check"
61
70
  },
62
71
  "optionalDependencies": {
63
72
  "split2": "^4.2.0"
64
73
  },
65
74
  "peerDependencies": {
66
- "pino": ">=8.0.0",
67
- "@sentry/node": "^10.74.0",
68
- "mysql2": "^3.24.4",
69
- "pg": "^8.23.0",
70
75
  "@opentelemetry/api": ">=1.9.0",
71
76
  "@opentelemetry/api-logs": "^0.222.0",
72
- "@opentelemetry/sdk-logs": "^0.222.0",
73
77
  "@opentelemetry/exporter-logs-otlp-http": "^0.222.0",
74
78
  "@opentelemetry/resources": "^2.11.0",
75
- "@opentelemetry/semantic-conventions": ">=1.20.0"
79
+ "@opentelemetry/sdk-logs": "^0.222.0",
80
+ "@opentelemetry/semantic-conventions": ">=1.20.0",
81
+ "@sentry/node": "^10.74.0",
82
+ "mysql2": "^3.24.4",
83
+ "pg": "^8.23.0",
84
+ "pino": ">=9.14.0",
85
+ "winston": "^3.19.0",
86
+ "winston-transport": "^4.9.0",
87
+ "eslint": ">=9.0.0"
76
88
  },
77
89
  "peerDependenciesMeta": {
78
90
  "pino": {
@@ -104,21 +116,36 @@
104
116
  },
105
117
  "@opentelemetry/semantic-conventions": {
106
118
  "optional": true
119
+ },
120
+ "winston": {
121
+ "optional": true
122
+ },
123
+ "winston-transport": {
124
+ "optional": true
125
+ },
126
+ "eslint": {
127
+ "optional": true
107
128
  }
108
129
  },
109
130
  "devDependencies": {
131
+ "@opentelemetry/context-async-hooks": "^2.11.0",
132
+ "@opentelemetry/exporter-logs-otlp-http": "^0.222.0",
133
+ "@opentelemetry/sdk-logs": "^0.222.0",
110
134
  "@sentry/node": "^10.74.0",
111
135
  "@sentry/profiling-node": "^10.74.0",
112
- "mysql2": "^3.24.4",
113
- "pg": "^8.23.0",
114
136
  "@testcontainers/mysql": "^12.1.0",
115
137
  "@testcontainers/postgresql": "^12.1.0",
116
138
  "dotenv": "^17.4.2",
139
+ "eslint": "^10.10.0",
140
+ "fastify": "^5.12.4",
117
141
  "jest": "^30.5.1",
118
- "testcontainers": "^12.1.0",
142
+ "mysql2": "^3.24.4",
143
+ "pg": "^8.23.0",
119
144
  "pino": "^10.3.1",
120
- "@opentelemetry/sdk-logs": "^0.222.0",
121
- "@opentelemetry/exporter-logs-otlp-http": "^0.222.0"
145
+ "testcontainers": "^12.1.0",
146
+ "typescript": "^5.9.3",
147
+ "winston": "^3.19.0",
148
+ "winston-transport": "^4.9.0"
122
149
  },
123
150
  "engines": {
124
151
  "node": ">=22.22.2"
package/pino.d.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  ChildLoggerOptions,
18
18
  DestinationStream,
19
19
  Logger as PinoLogger,
20
+ LoggerOptions,
20
21
  } from 'pino';
21
22
 
22
23
  /**
@@ -126,6 +127,9 @@ declare namespace ntlPinoTransport {
126
127
 
127
128
  /** Snapshot returned by `logger.getStats()`. Members are `null` when unconfigured. */
128
129
  export interface NtlPinoStats {
130
+ levels: Record<NtlLevel, number>;
131
+ hookErrors: number;
132
+ onLog: { pending: number; dropped: number; limit: number };
129
133
  sampling: SamplingStats | null;
130
134
  deduplication: DeduplicationStats | null;
131
135
  }
@@ -175,6 +179,12 @@ declare namespace ntlPinoTransport {
175
179
 
176
180
  /** Options for `createLogger()`. */
177
181
  export interface CreateLoggerOptions {
182
+ /** Receives a detached redacted record; async work is drained by close(). */
183
+ /** Maximum in-flight callback promises; overflow drops newest callback, not the primary log. */
184
+ onLogMaxPending?: number;
185
+ onLog?: (record: Record<string, any>) => void | Promise<void>;
186
+ /** Use the application's OTel provider; never registers or shuts down a provider. */
187
+ otel?: boolean | { loggerProvider?: { getLogger(name: string): { emit(record: any): void }; forceFlush?(): Promise<void> }; name?: string };
178
188
  /** Pino level. Defaults to `LOG_LEVEL`, else `debug` in dev / `info` in production. */
179
189
  level?: string;
180
190
  /** Module label added to every line and used as the transport's default label. */
@@ -214,13 +224,13 @@ declare namespace ntlPinoTransport {
214
224
  * properties, so children inherit them through the prototype chain.
215
225
  */
216
226
  export interface NtlPinoExtras {
217
- /** Sampling and deduplication counters. */
227
+ /** Emitted level counts, callback/export hook errors, sampling and deduplication. */
218
228
  getStats(): NtlPinoStats;
219
- /** Zero the sampling and deduplication counters. */
229
+ /** Zero all counters shared by the root and its children. */
220
230
  resetStats(): void;
221
231
  /**
222
232
  * Release sampler/deduplicator timers, then drain the destination.
223
- * Calling this on a child tears down state shared with its parent.
233
+ * Closing a child leaves its parent open. Calls after close throw, even at disabled levels.
224
234
  */
225
235
  close(timeout?: number): Promise<DrainResult>;
226
236
  /** Present only when `processHandlers` was enabled. */
@@ -237,10 +247,24 @@ declare namespace ntlPinoTransport {
237
247
  export type NtlPinoLogger = NtlPinoExtras & PinoLogger;
238
248
 
239
249
  /** Create a pre-configured Pino logger that follows NightTimeLogger conventions. */
250
+ export function withSecretValues<T>(values: string[], callback: () => T): T;
251
+
240
252
  export function createLogger(opts?: CreateLoggerOptions): NtlPinoLogger;
241
253
 
254
+ /** Framework-owned JSON logger options; no ntlogger lifecycle or timers. */
255
+ export type CreatePinoOptions = Pick<CreateLoggerOptions,
256
+ 'level' | 'module' | 'service' | 'context' | 'contextProvider' | 'contextKeys' | 'redact' | 'silent' | 'onLog' | 'onLogMaxPending' | 'otel'
257
+ > & { serializers?: LoggerOptions['serializers'] };
258
+ export function createPinoOptions(opts?: CreatePinoOptions): LoggerOptions;
259
+
260
+ /** Synchronous capture of serialized, redacted records; explicitly silent loggers capture nothing. */
261
+ export function createTestLogger(opts?: Omit<CreateLoggerOptions, 'destination' | 'processHandlers'>): {
262
+ logger: NtlPinoLogger;
263
+ records: Array<Record<string, any>>;
264
+ };
265
+
242
266
  /**
243
- * Resolve silent mode: `opts.silent` -> `NTLOGGER_SILENT` -> `NODE_ENV === 'test'`.
267
+ * Resolve silent mode: `opts.silent` -> `NTLOGGER_SILENT` -> `NODE_ENV === 'test'` or nonempty `NODE_TEST_CONTEXT`.
244
268
  */
245
269
  export function isSilent(opts?: { silent?: boolean }): boolean;
246
270