@xeplr/logs 1.0.0 → 1.0.1

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.
Files changed (3) hide show
  1. package/index.js +1 -0
  2. package/lib/logger.js +679 -86
  3. package/package.json +19 -5
package/index.js CHANGED
@@ -5,6 +5,7 @@ module.exports = {
5
5
  createLogger: logger.createLogger,
6
6
  requestLogger: logger.requestLogger,
7
7
  purge: logger.purge,
8
+ flush: logger.flush,
8
9
  destroy: logger.destroy,
9
10
  LEVELS: logger.LEVELS
10
11
  };
package/lib/logger.js CHANGED
@@ -1,134 +1,686 @@
1
1
  var fs = require('fs');
2
2
  var path = require('path');
3
3
 
4
+ // ─────────────────────────────────────────────────────────────────────────
5
+ // THE TWO QUESTIONS EVERY LOG LINE ANSWERS
6
+ //
7
+ // 1. WHAT is it about? → `referenceId`
8
+ // 2. WHERE should it be kept? → `to`
9
+ //
10
+ // (1) is the identity of the unit of work the line belongs to: a job
11
+ // occurrence, a move run, an action run. It used to be called `sessionId`,
12
+ // which was wrong in a way that caused a bug — "session" reads as HTTP
13
+ // session, so requestLogger felt entitled to INVENT one for any request that
14
+ // arrived without a header. That made "this line belongs to a run" and "this
15
+ // line belongs to nothing" the same shape, which is exactly the distinction
16
+ // the whole design turns on. No key now means no key.
17
+ //
18
+ // (2) decides the destination: an app-level file, a file per referenceId, or
19
+ // rows in a database the host owns. Bound when the logger is created, and
20
+ // overridable per entry.
21
+ //
22
+ // ─────────────────────────────────────────────────────────────────────────
23
+ // CONSOLE IS ALWAYS WRITTEN, AND IT IS NEVER BUFFERED
24
+ //
25
+ // Whichever destination a line is bound for — app file, session file, database
26
+ // — it goes to the console too, gated only by `consoleLevel`. The console is
27
+ // not a destination you choose between; it is unconditional.
28
+ //
29
+ // That is what makes buffering everything else safe. A process that dies with
30
+ // 200 lines pending loses them, and those are the lines explaining why it
31
+ // died — so the usual answer is a synchronous write-through on error, which is
32
+ // slow and STILL loses the twenty info lines before it that carry the story.
33
+ // Here there is nothing to write through: by the time a line entered the
34
+ // buffer it had already been printed. Whatever is watching stdout — PM2,
35
+ // Docker, a terminal, systemd — already has it.
36
+ //
37
+ // That is a bonus copy, NOT a dependency. This package does not require PM2,
38
+ // does not require pm2-logrotate, and does not assume anything is capturing
39
+ // stdout at all. It keeps its own text files and its own retention (purge()).
40
+ // If the surrounding infrastructure also keeps a copy, that is the
41
+ // infrastructure's business and duplication is cheap.
42
+ //
43
+ // `consoleLevel` defaults to `important` in production for volume, not for
44
+ // safety: twenty concurrent runs' batch chatter on stdout helps nobody. The
45
+ // levels that must survive a crash are exactly the ones still printed.
46
+ //
47
+ // ─────────────────────────────────────────────────────────────────────────
48
+ // WHY THE LEVEL GATE IS THE FIRST THING _write DOES
49
+ //
50
+ // The previous version built the timestamp, concatenated four strings and
51
+ // JSON.stringify'd the metadata BEFORE deciding anything — and then always
52
+ // wrote to file, at every level, with no way to switch debug off at all. On a
53
+ // single-core box that is the dominant cost of logging, and no setting could
54
+ // stop it.
55
+ //
56
+ // So: resolve the destinations first, and if a line is going nowhere, return
57
+ // before touching a string. A disabled log.debug now costs one integer
58
+ // comparison. Formatting for the store is deferred to flush time — the buffer
59
+ // holds raw fields, not finished lines.
60
+ //
61
+ // ─────────────────────────────────────────────────────────────────────────
62
+ // WHY BATCHING, GIVEN fs WRITE STREAMS ARE ALREADY ASYNC
63
+ //
64
+ // Non-blocking is not free. Every line costs a syscall and a threadpool
65
+ // handoff, and where something is also capturing stdout it costs a second
66
+ // syscall in that process too. On one core all of them take turns on the same
67
+ // CPU, paying a context switch each way. So batching is not about unblocking
68
+ // the event loop — that was never blocked; it is about doing fewer syscalls
69
+ // and fewer switches, which is exactly the cost that bites when there is no
70
+ // spare core.
71
+ //
72
+ // Both destinations are buffered, for that reason. The database is where it
73
+ // stops being an optimisation and becomes the difference between viable and
74
+ // not: 200 lines is 200 round trips unbatched, and one insert batched.
75
+ // ─────────────────────────────────────────────────────────────────────────
76
+
4
77
  var LEVELS = { debug: 1, info: 2, important: 3, error: 4, critical: 5 };
5
78
  var LEVEL_NAMES = ['', 'DEBUG', 'INFO', 'IMPORTANT', 'ERROR', 'CRITICAL'];
6
79
 
80
+ // Destinations. `console` is not one of them — console is orthogonal and
81
+ // always applies, gated only by consoleLevel.
82
+ var DESTINATIONS = { app: true, session: true, db: true };
83
+
7
84
  var _config = {
8
85
  appName: 'app',
9
86
  logDir: './logs',
10
- fatalThreshold: 4,
87
+ isDev: true,
88
+
89
+ // What reaches stdout. Null means "derive from isDev" — debug in
90
+ // development, important in production. Set it explicitly to override.
91
+ consoleLevel: null,
92
+
93
+ // What reaches the store (app file / session file / database). Independent
94
+ // of the console, on purpose: a run's own store wants its info lines, while
95
+ // the console must not carry twenty concurrent runs' batch chatter.
96
+ //
97
+ // debug is OFF by default and switched on at runtime — configure() merges,
98
+ // so an app can raise it WHILE something is misbehaving rather than needing
99
+ // a restart that clears the state it was trying to look at.
100
+ storeLevel: 'info',
101
+
102
+ // to:'db' writer, injected. This package has no dependencies and does not
103
+ // own a database — same rule as everywhere else in the workspace. The host
104
+ // supplies one function; it is called with an ARRAY of records.
105
+ store: null,
106
+
107
+ // Destination for a logger that has a referenceId but was not told where to
108
+ // put things.
109
+ //
110
+ // 'app' — the dated <appName>-<date>.log, which is where everything went
111
+ // before and where anything unremarkable should keep going.
112
+ //
113
+ // NOT 'session', and the reason bit immediately when it was: every existing
114
+ // call site passes a CATEGORY as its key — createLogger('jobs'),
115
+ // createLogger('movement') — not a run id. Defaulting those to a session
116
+ // file would open xeplr-bi-jobs.log and append to it forever, and purge()
117
+ // could never reclaim it, because purge deletes by mtime and that file is
118
+ // written every minute. The app file has a date in its name and rolls on its
119
+ // own; a session file does not, because a run belongs to ONE file whether or
120
+ // not it crosses midnight.
121
+ //
122
+ // So: a session file assumes a bounded unit of work. Ask for it explicitly,
123
+ // with a real run id — createLogger({ referenceId: occId, to: 'session' }) —
124
+ // or point all keyed logging at the database with
125
+ // configure({ defaultTo: 'db' }).
126
+ defaultTo: 'app',
127
+
128
+ // HOW A FILE LINE IS WRITTEN: 'text' or 'ndjson'.
129
+ //
130
+ // text is for a person tailing a file. ndjson is for a PROGRAM — one JSON
131
+ // record per line, the same fields the database store receives, so a log
132
+ // analyzer has one record shape whether the lines came from a table or a
133
+ // file. Reports keep their logs in files and jobs keep theirs in the
134
+ // database; without this that difference would mean two readers, one of them
135
+ // a parser for our own prose.
136
+ //
137
+ // Text stays the default: a file nobody can read at a glance is a worse
138
+ // default than one no program can read.
139
+ fileFormat: 'text',
140
+
141
+ // Write <appName>-<date>.log ourselves.
142
+ //
143
+ // ON. This package keeps its own text record and its own retention, and does
144
+ // not care whether something outside is also capturing stdout. Where PM2 is
145
+ // in front of it there are two copies of the same lines — that is fine and
146
+ // deliberate: a duplicated write is cheap, and a package that only works
147
+ // when deployed a particular way is not a package.
148
+ //
149
+ // Turn it off only if you have decided the surrounding infrastructure is the
150
+ // record.
151
+ appFile: true,
152
+
153
+ // Flush cadence. Whichever comes first.
154
+ flushMs: 1000,
155
+ maxBatch: 200,
156
+
157
+ // Email alerts.
11
158
  emailThreshold: 5,
12
159
  emailTo: null,
13
160
  emailService: null,
14
- isDev: true
161
+
162
+ // A FOURTH DESTINATION FOR ERRORS — see _notifySink.
163
+ //
164
+ // This package writes text, which is the right shape for "what happened
165
+ // during X" and the wrong shape for "has anything broken at all". The second
166
+ // question needs rows: something you can list newest-first, count
167
+ // occurrences on, mark as dealt with, and read from a machine that did not
168
+ // write the file.
169
+ //
170
+ // Rather than ask every app to report failures TWICE (a call that will be
171
+ // missing in exactly the place it matters), an app that wants rows supplies
172
+ // one function here and keeps calling log.error as normal.
173
+ onError: null,
174
+ sinkThreshold: 4,
175
+
176
+ // How long this app's own log files are kept. NULL = never purge, because
177
+ // deleting an app's files is not something a library should start doing on
178
+ // its own. Set it and purging schedules itself — see _schedulePurge.
179
+ retentionDays: null,
180
+
181
+ // Accepted for backward compatibility. consoleLevel supersedes it.
182
+ fatalThreshold: 4
15
183
  };
16
184
 
17
- var _streams = {};
18
- var _currentDate = null;
185
+ // Raw records awaiting a flush. NOT formatted — see the header.
186
+ var _buffer = [];
187
+ var _timer = null;
188
+ var _purgeTimer = null;
189
+ var _flushing = false;
190
+ var _warned = {};
19
191
 
20
192
  function configure(config) {
193
+ if (!config) return;
194
+
21
195
  if (config.appName) _config.appName = config.appName;
22
196
  if (config.logDir) _config.logDir = config.logDir;
197
+ if (config.isDev !== undefined) _config.isDev = config.isDev;
198
+
199
+ if (config.consoleLevel !== undefined) _config.consoleLevel = config.consoleLevel;
200
+ if (config.storeLevel !== undefined) _config.storeLevel = config.storeLevel;
201
+
202
+ if (config.store !== undefined) _config.store = config.store;
203
+ if (config.defaultTo) _config.defaultTo = config.defaultTo;
204
+ if (config.appFile !== undefined) _config.appFile = config.appFile;
205
+ if (config.fileFormat) _config.fileFormat = config.fileFormat;
206
+
207
+ if (config.flushMs !== undefined) _config.flushMs = config.flushMs;
208
+ if (config.maxBatch !== undefined) _config.maxBatch = config.maxBatch;
209
+
23
210
  if (config.fatalThreshold !== undefined) _config.fatalThreshold = config.fatalThreshold;
24
211
  if (config.emailThreshold !== undefined) _config.emailThreshold = config.emailThreshold;
25
212
  if (config.emailTo) {
26
213
  _config.emailTo = Array.isArray(config.emailTo) ? config.emailTo : [config.emailTo];
27
214
  }
28
215
  if (config.emailService) _config.emailService = config.emailService;
29
- if (config.isDev !== undefined) _config.isDev = config.isDev;
30
216
 
31
- var dir = path.resolve(_config.logDir);
32
- if (!fs.existsSync(dir)) {
33
- fs.mkdirSync(dir, { recursive: true });
217
+ // Explicit null clears it — an app must be able to switch the sink off (in a
218
+ // test, or before its database is up) without reloading the module.
219
+ if (config.onError !== undefined) _config.onError = config.onError;
220
+ if (config.sinkThreshold !== undefined) _config.sinkThreshold = config.sinkThreshold;
221
+
222
+ if (config.retentionDays !== undefined) {
223
+ _config.retentionDays = config.retentionDays;
224
+ _schedulePurge();
34
225
  }
226
+
227
+ // Only make the directory if something will actually write into it.
228
+ if (_config.appFile || _config.defaultTo === 'session') _ensureDir();
229
+ }
230
+
231
+ function _ensureDir() {
232
+ var dir = path.resolve(_config.logDir);
233
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
234
+ return dir;
35
235
  }
36
236
 
237
+ function _consoleMin() {
238
+ if (_config.consoleLevel) return LEVELS[_config.consoleLevel] || 1;
239
+ return _config.isDev ? LEVELS.debug : LEVELS.important;
240
+ }
241
+
242
+ function _storeMin() {
243
+ return LEVELS[_config.storeLevel] || LEVELS.info;
244
+ }
245
+
246
+ // MEMOISED, because this used to run once per record at flush time — a
247
+ // 200-line batch built 200 Date objects and formatted 200 identical strings.
248
+ //
249
+ // Cached with an EXPIRY rather than refreshed on a timer. A timer that ticks
250
+ // hourly leaves lines written just after midnight landing in yesterday's file,
251
+ // and costs a wakeup in an otherwise idle process; comparing against the next
252
+ // UTC midnight is exact, recomputes once a day, and needs no timer at all.
253
+ // Date.now() is a vDSO read with no allocation — the comparison is free next
254
+ // to the toISOString it replaces.
255
+ var _dateStr = null;
256
+ var _dateUntil = 0;
37
257
  function _today() {
38
- return new Date().toISOString().slice(0, 10);
258
+ var now = Date.now();
259
+ if (now >= _dateUntil) {
260
+ var d = new Date(now);
261
+ _dateStr = d.toISOString().slice(0, 10);
262
+ _dateUntil = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
263
+ }
264
+ return _dateStr;
39
265
  }
40
266
 
41
- function _getStream(sessionId) {
42
- var today = _today();
267
+ // A referenceId becomes part of a FILENAME, so it is sanitised rather than
268
+ // trusted. An id containing '/' or '..' would otherwise write outside logDir,
269
+ // and ids arrive from callers this package does not control.
270
+ function _safeRef(referenceId) {
271
+ return String(referenceId)
272
+ .replace(/[^A-Za-z0-9._-]/g, '_')
273
+ // Separators are already gone, so '..' cannot traverse — but a filename
274
+ // made of dots is still nobody's intent, and leaving it would mean reading
275
+ // the sanitiser twice to be sure.
276
+ .replace(/\.{2,}/g, '_')
277
+ .slice(0, 120);
278
+ }
43
279
 
44
- // Daily rotation — close old streams if date changed
45
- if (_currentDate && _currentDate !== today) {
46
- Object.keys(_streams).forEach(function(key) {
47
- _streams[key].end();
48
- });
49
- _streams = {};
280
+ function _warnOnce(key, message) {
281
+ if (_warned[key]) return;
282
+ _warned[key] = true;
283
+ console.error('[@xeplr/logs] ' + message);
284
+ }
285
+
286
+ /**
287
+ * The one path every log line takes.
288
+ *
289
+ * Order matters: decide, then format, then write. Never the other way round.
290
+ */
291
+ function _write(level, referenceId, message, metadata, entryTo) {
292
+ var levelNum = LEVELS[level] || LEVELS.info;
293
+
294
+ // ── 1. DECIDE. No strings built yet. ────────────────────────────────────
295
+ var toConsole = levelNum >= _consoleMin();
296
+ var toStore = levelNum >= _storeMin();
297
+ var toSink = Boolean(_config.onError) && levelNum >= _config.sinkThreshold;
298
+ var toEmail = Boolean(_config.emailTo && _config.emailService) && levelNum >= _config.emailThreshold;
299
+
300
+ var dest = entryTo || null;
301
+ if (dest && !DESTINATIONS[dest]) {
302
+ _warnOnce('dest:' + dest, 'unknown destination "' + dest + '" — falling back to console only.');
303
+ dest = null;
304
+ toStore = false;
305
+ }
306
+
307
+ if (toStore && !dest) dest = referenceId ? _config.defaultTo : 'app';
308
+
309
+ // An app file nobody asked for is two copies of what PM2 already keeps.
310
+ if (toStore && dest === 'app' && !_config.appFile) toStore = false;
311
+
312
+ // to:'db' with no referenceId is meaningless — the row could never be found
313
+ // again. Degrade rather than throw: a mistake at one call site must not take
314
+ // down the request it was logging. createLogger throws for the same mistake
315
+ // made in configuration, where it is caught immediately.
316
+ if (toStore && dest === 'db' && !referenceId) {
317
+ _warnOnce('db-noref', 'to:"db" needs a referenceId — line kept on console only.');
318
+ toStore = false;
319
+ }
320
+ if (toStore && dest === 'db' && !_config.store) {
321
+ _warnOnce('db-nostore', 'to:"db" needs configure({ store }) — line kept on console only.');
322
+ toStore = false;
50
323
  }
51
- _currentDate = today;
324
+ if (toStore && dest === 'session' && !referenceId) {
325
+ dest = 'app';
326
+ if (!_config.appFile) toStore = false;
327
+ }
328
+
329
+ if (!toConsole && !toStore && !toSink && !toEmail) return;
330
+
331
+ var sid = referenceId || _config.appName;
52
332
 
53
- var key = sessionId + ':' + today;
54
- if (!_streams[key]) {
55
- var dir = path.resolve(_config.logDir);
56
- if (!fs.existsSync(dir)) {
57
- fs.mkdirSync(dir, { recursive: true });
333
+ // ── 2. CONSOLE. Unbuffered, because it is the durable record. ───────────
334
+ if (toConsole) {
335
+ var line = '[' + new Date().toISOString() + '] [' + sid + '] [' +
336
+ LEVEL_NAMES[levelNum] + '] ' + message;
337
+ if (metadata) {
338
+ line += ' ' + (typeof metadata === 'string' ? metadata : _safeJson(metadata));
58
339
  }
59
- var filename = sessionId + '-' + today + '.log';
60
- _streams[key] = fs.createWriteStream(path.join(dir, filename), { flags: 'a' });
340
+ if (levelNum >= LEVELS.error) console.error(line);
341
+ else console.log(line);
342
+ }
343
+
344
+ // ── 3. STORE. Raw fields only; formatting happens at flush. ─────────────
345
+ if (toStore) {
346
+ _buffer.push({
347
+ to: dest,
348
+ at: Date.now(),
349
+ level: level,
350
+ levelNum: levelNum,
351
+ appName: _config.appName,
352
+ referenceId: referenceId || null,
353
+ message: String(message == null ? '' : message),
354
+ meta: metadata || null
355
+ });
356
+ if (_buffer.length >= _config.maxBatch) flush();
357
+ else _arm();
358
+ }
359
+
360
+ if (toEmail) _sendAlert(LEVEL_NAMES[levelNum], sid, message, metadata);
361
+
362
+ // Rows. LAST, so that a slow or broken sink cannot delay or prevent
363
+ // anything above it.
364
+ if (toSink) _notifySink(level, LEVEL_NAMES[levelNum], sid, message, metadata);
365
+ }
366
+
367
+ // Metadata is whatever a caller passed. A circular object would otherwise
368
+ // throw INSIDE a log call, which is the worst possible place for it — most
369
+ // often while something is already failing.
370
+ function _safeJson(value) {
371
+ try { return JSON.stringify(value); }
372
+ catch (e) { return '[unserialisable metadata: ' + (e && e.message) + ']'; }
373
+ }
374
+
375
+ function _arm() {
376
+ if (_timer || !_config.flushMs) return;
377
+ _timer = setTimeout(function() { _timer = null; flush(); }, _config.flushMs);
378
+ // Never hold the process open for a log flush.
379
+ if (_timer.unref) _timer.unref();
380
+ }
381
+
382
+ /**
383
+ * Write everything buffered. Returns a promise, so shutdown and tests can wait
384
+ * for it; nothing in the hot path ever does.
385
+ *
386
+ * Re-entrancy: a flush already in progress returns the same promise rather
387
+ * than starting a second one, or two timers could interleave and write the
388
+ * same records twice.
389
+ */
390
+ var _pending = null;
391
+ function flush() {
392
+ if (_flushing) return _pending;
393
+ if (!_buffer.length) return Promise.resolve();
394
+
395
+ _flushing = true;
396
+ var batch = _buffer;
397
+ _buffer = [];
398
+ if (_timer) { clearTimeout(_timer); _timer = null; }
399
+
400
+ _pending = _drain(batch).then(finish, finish);
401
+ function finish() {
402
+ _flushing = false;
403
+ _pending = null;
404
+ // WHAT ARRIVED WHILE WE WERE WRITING.
405
+ //
406
+ // A synchronous burst — a traced call chain, a loop that logs — fills the
407
+ // buffer past maxBatch over and over, and every one of those flush() calls
408
+ // returned early because this one was still in flight. The write path
409
+ // takes the else branch that arms the timer ONLY when the buffer is under
410
+ // maxBatch, so nothing rescheduled the remainder: it sat in memory,
411
+ // growing, until some later line happened to arrive under the threshold.
412
+ //
413
+ // Picking up here is what makes sustained logging drain rather than
414
+ // accumulate.
415
+ if (_buffer.length >= _config.maxBatch) flush();
416
+ else if (_buffer.length) _arm();
61
417
  }
62
- return _streams[key];
418
+ return _pending;
63
419
  }
64
420
 
65
- function _write(level, sessionId, message, metadata) {
66
- var levelNum = LEVELS[level] || 2;
67
- var levelLabel = LEVEL_NAMES[levelNum];
68
- var ts = new Date().toISOString();
69
- var sid = sessionId || _config.appName;
421
+ function _drain(batch) {
422
+ var files = {}; // absolute path → text to append
423
+ var rows = [];
424
+
425
+ for (var i = 0; i < batch.length; i++) {
426
+ var r = batch[i];
427
+ if (r.to === 'db') { rows.push(r); continue; }
428
+
429
+ // TWO SHAPES, kept visibly different on purpose:
430
+ // <appName>.<referenceId>.log one unit of work, no date — a run
431
+ // belongs to one file even across midnight
432
+ // <appName>-<date>.log the app's own stream, rolling daily
433
+ // Both carry the app name, so one directory can hold several apps.
434
+ var file = r.to === 'session'
435
+ ? path.join(_dir(), _config.appName + '.' + _safeRef(r.referenceId) + '.log')
436
+ : path.join(_dir(), _config.appName + '-' + _today() + '.log');
437
+
438
+ var text;
439
+ if (_config.fileFormat === 'ndjson') {
440
+ // The SAME fields the db store gets — see logStore in a host app. One
441
+ // record shape, two destinations, so nothing has to parse prose.
442
+ text = _safeJson({
443
+ at: new Date(r.at).toISOString(),
444
+ level: LEVEL_NAMES[r.levelNum],
445
+ appName: r.appName,
446
+ referenceId: r.referenceId || null,
447
+ message: r.message,
448
+ meta: r.meta || null
449
+ });
450
+ } else {
451
+ text = '[' + new Date(r.at).toISOString() + '] [' +
452
+ (r.referenceId || r.appName) + '] [' + LEVEL_NAMES[r.levelNum] + '] ' + r.message;
453
+ if (r.meta) text += ' ' + (typeof r.meta === 'string' ? r.meta : _safeJson(r.meta));
454
+ }
70
455
 
71
- var line = '[' + ts + '] [' + sid + '] [' + levelLabel + '] ' + message;
72
- if (metadata) {
73
- line += ' ' + (typeof metadata === 'string' ? metadata : JSON.stringify(metadata));
456
+ files[file] = (files[file] || '') + text + '\n';
74
457
  }
75
458
 
76
- // Always write to file
77
- var stream = _getStream(_config.appName);
78
- stream.write(line + '\n');
459
+ var work = Object.keys(files).map(function(file) {
460
+ return new Promise(function(resolve) {
461
+ // appendFile, not a held-open stream. A file per referenceId means
462
+ // thousands of descriptors if they are kept; batching already made one
463
+ // call per file per flush, so opening and closing costs nothing extra.
464
+ fs.appendFile(file, files[file], function() { resolve(); });
465
+ });
466
+ });
79
467
 
80
- // Console: errors/critical always, rest only in dev or >= fatalThreshold
81
- if (levelNum >= 4) {
82
- console.error(line);
83
- } else if (_config.isDev || levelNum >= _config.fatalThreshold) {
84
- console.log(line);
468
+ if (rows.length && _config.store) {
469
+ work.push(Promise.resolve()
470
+ .then(function() { return _config.store(rows); })
471
+ .catch(function(e) {
472
+ // The store being down must not become a second failure. The lines are
473
+ // on the console already — see the header.
474
+ _warnOnce('store-failed', 'store() threw, ' + rows.length +
475
+ ' row(s) dropped: ' + ((e && e.message) || e));
476
+ }));
85
477
  }
86
478
 
87
- // Email
88
- if (_config.emailTo && _config.emailService && levelNum >= _config.emailThreshold) {
89
- _sendAlert(levelLabel, sid, message, metadata);
479
+ return Promise.all(work);
480
+ }
481
+
482
+ var _dirCache = null;
483
+ function _dir() {
484
+ if (!_dirCache) _dirCache = _ensureDir();
485
+ return _dirCache;
486
+ }
487
+
488
+ /**
489
+ * Hand the error to whatever the app wants to do with it — usually insert a row.
490
+ *
491
+ * FIRE AND FORGET, and it swallows everything. Two separate reasons, both
492
+ * learned the hard way elsewhere:
493
+ *
494
+ * - _write is SYNCHRONOUS and every caller treats it as such. A database
495
+ * insert is not, so awaiting it here would make every log.error in the
496
+ * codebase an await point, or silently return an unhandled promise.
497
+ * - This runs while the app is ALREADY handling a failure. A sink that
498
+ * throws would replace the real error — the one somebody needs to read —
499
+ * with whatever went wrong recording it.
500
+ */
501
+ function _notifySink(level, levelLabel, referenceId, message, metadata) {
502
+ try {
503
+ var out = _config.onError({
504
+ level: level,
505
+ levelLabel: levelLabel,
506
+ appName: _config.appName,
507
+ // Kept under BOTH names. `sessionId` is what errorEvents.js and every
508
+ // other existing sink reads; renaming it here would silently blank a
509
+ // column rather than fail.
510
+ sessionId: referenceId,
511
+ referenceId: referenceId,
512
+ message: String(message == null ? '' : message),
513
+ meta: metadata || null,
514
+ at: new Date()
515
+ });
516
+ if (out && typeof out.then === 'function') out.then(null, function() {});
517
+ } catch (e) {
518
+ // Deliberately silent. See above.
90
519
  }
91
520
  }
92
521
 
93
- function _sendAlert(levelLabel, sessionId, message, metadata) {
94
- var subject = levelLabel + ': [' + _config.appName + '] [' + sessionId + '] ' + message.slice(0, 80);
522
+ function _sendAlert(levelLabel, referenceId, message, metadata) {
523
+ var subject = levelLabel + ': [' + _config.appName + '] [' + referenceId + '] ' +
524
+ String(message).slice(0, 80);
95
525
  var html = '<h2 style="color:red;">' + levelLabel + '</h2>'
96
526
  + '<p><strong>App:</strong> ' + _config.appName + '</p>'
97
- + '<p><strong>Session:</strong> ' + sessionId + '</p>'
527
+ + '<p><strong>Reference:</strong> ' + referenceId + '</p>'
98
528
  + '<p><strong>Message:</strong> ' + message + '</p>'
99
- + (metadata ? '<pre>' + JSON.stringify(metadata, null, 2) + '</pre>' : '');
529
+ + (metadata ? '<pre>' + _safeJson(metadata) + '</pre>' : '');
100
530
 
101
531
  try {
102
532
  _config.emailService.send(_config.emailTo, subject, html);
103
533
  } catch (e) {
104
- // Don't let email failure crash the app
534
+ // Don't let email failure crash the app.
105
535
  }
106
536
  }
107
537
 
108
538
  /**
109
- * Create a logger bound to a session ID.
110
- * All methods: debug, info, important, error, critical
539
+ * A logger bound to one unit of work.
540
+ *
541
+ * createLogger('jobs') // referenceId only
542
+ * createLogger({ referenceId: occId, to: 'db' }) // and a destination
543
+ *
544
+ * Every method takes (message, meta, opts?) where opts.to overrides the bound
545
+ * destination for that entry.
111
546
  */
112
- function createLogger(sessionId) {
113
- var sid = sessionId || _config.appName;
547
+ function createLogger(options) {
548
+ var referenceId = null;
549
+ var to = null;
550
+
551
+ if (typeof options === 'string' || typeof options === 'number') {
552
+ referenceId = String(options);
553
+ } else if (options && typeof options === 'object') {
554
+ referenceId = options.referenceId != null ? String(options.referenceId) : null;
555
+ to = options.to || null;
556
+ }
557
+
558
+ // Configuration mistakes throw HERE, where the stack points at the code that
559
+ // made them. The same mistake made per-entry degrades to console instead —
560
+ // see _write. A logger built wrong is a bug; one line logged wrong is not
561
+ // worth an outage.
562
+ if (to && !DESTINATIONS[to]) {
563
+ throw new Error('@xeplr/logs: unknown destination "' + to +
564
+ '". Use one of: app, session, db.');
565
+ }
566
+ if (to === 'db' && !referenceId) {
567
+ throw new Error('@xeplr/logs: to:"db" requires a referenceId — a row with ' +
568
+ 'nothing to find it by can never be read back.');
569
+ }
570
+
571
+ function at(level) {
572
+ return function(msg, meta, opts) {
573
+ _write(level, referenceId, msg, meta, (opts && opts.to) || to);
574
+ };
575
+ }
576
+
114
577
  return {
115
- debug: function(msg, meta) { _write('debug', sid, msg, meta); },
116
- info: function(msg, meta) { _write('info', sid, msg, meta); },
117
- important: function(msg, meta) { _write('important', sid, msg, meta); },
118
- error: function(msg, meta) { _write('error', sid, msg, meta); },
119
- critical: function(msg, meta) { _write('critical', sid, msg, meta); }
578
+ debug: at('debug'),
579
+ info: at('info'),
580
+ important: at('important'),
581
+ error: at('error'),
582
+ critical: at('critical'),
583
+ referenceId: referenceId,
584
+
585
+ /**
586
+ * For call sites whose metadata is expensive to build.
587
+ *
588
+ * The level gate stops _write doing work, but it cannot stop the ARGUMENTS
589
+ * being evaluated — log.debug('x', summarise(rows)) still calls summarise
590
+ * even when debug is off. Guard those with this.
591
+ */
592
+ isEnabled: function(level) {
593
+ var n = LEVELS[level] || LEVELS.info;
594
+ return n >= _consoleMin() || n >= _storeMin();
595
+ },
596
+
597
+ /**
598
+ * ENTRY AND EXIT FOR ONE FUNCTION, with how long it took, at debug level.
599
+ *
600
+ * var activate = log.trace('activate', async function (token) { ... });
601
+ *
602
+ * ── WHY THIS EXISTS RATHER THAN TWO log.debug CALLS ──────────────────
603
+ *
604
+ * Hand-written entry/exit lines cost something even when debug is OFF:
605
+ * `log.debug('enter activate ' + token)` builds that string, every call,
606
+ * and only then discovers nobody wanted it. Twenty-five functions deep in
607
+ * a request, that is the difference between free and seconds.
608
+ *
609
+ * Here the FIRST thing is the gate, and when it is shut the wrapper is a
610
+ * single boolean test and a straight call through — no strings, no
611
+ * timestamps, no allocation. Turn debug on at runtime (configure() merges,
612
+ * so no restart) and the same code starts narrating immediately.
613
+ *
614
+ * Works on sync and async functions alike: a returned thenable is timed to
615
+ * its settlement, anything else to its return. Errors are logged with the
616
+ * elapsed time and RE-THROWN — this observes, it never swallows.
617
+ */
618
+ trace: function(name, fn) {
619
+ var logger = this;
620
+ return function() {
621
+ // The gate, before anything is built. Everything below this line is
622
+ // work that only happens when somebody asked to watch.
623
+ if (!logger.isEnabled('debug')) return fn.apply(this, arguments);
624
+
625
+ var startedAt = Date.now();
626
+ logger.debug('→ ' + name);
627
+
628
+ function done(outcome, err) {
629
+ var ms = Date.now() - startedAt;
630
+ if (err) logger.debug('✗ ' + name + ' failed after ' + ms + 'ms: ' + err.message);
631
+ else logger.debug('← ' + name + ' ' + ms + 'ms');
632
+ }
633
+
634
+ var out;
635
+ try {
636
+ out = fn.apply(this, arguments);
637
+ } catch (err) {
638
+ done('threw', err);
639
+ throw err;
640
+ }
641
+ if (out && typeof out.then === 'function') {
642
+ return out.then(
643
+ function(v) { done('resolved'); return v; },
644
+ function(err) { done('rejected', err); throw err; }
645
+ );
646
+ }
647
+ done('returned');
648
+ return out;
649
+ };
650
+ },
651
+
652
+ /**
653
+ * The same, inline, for a block you do not want to name as a function.
654
+ *
655
+ * await log.span('resolve columns', function () { ... });
656
+ */
657
+ span: function(name, fn) {
658
+ return this.trace(name, fn)();
659
+ },
660
+
661
+ /** A child for a different unit of work, keeping this one's destination. */
662
+ for: function(nextReferenceId) {
663
+ return createLogger({ referenceId: nextReferenceId, to: to });
664
+ }
120
665
  };
121
666
  }
122
667
 
123
668
  /**
124
669
  * Express middleware — logs request entry/exit.
125
- * Reads session ID from x-session-id header, or generates one.
670
+ *
671
+ * NO FABRICATED ID. The previous version generated `sid-a1b2c3d4` for any
672
+ * request arriving without an x-reference-id header, which made an unkeyed
673
+ * line indistinguishable from a keyed one — defeating the only distinction
674
+ * this package's storage decisions are based on. A request that is not part of
675
+ * a tracked unit of work has no referenceId, and that is the correct answer.
126
676
  */
127
677
  function requestLogger() {
128
678
  return function(req, res, next) {
129
- var sessionId = req.headers['x-session-id'] || _generateSessionId();
130
- req.sessionId = sessionId;
131
- req.log = createLogger(sessionId);
679
+ var referenceId = req.headers['x-reference-id'] || req.headers['x-session-id'] || null;
680
+ req.referenceId = referenceId;
681
+ // Kept: existing middleware and routes read req.sessionId.
682
+ req.sessionId = referenceId;
683
+ req.log = createLogger(referenceId);
132
684
 
133
685
  var method = req.method;
134
686
  var url = req.originalUrl || req.url;
@@ -144,48 +696,88 @@ function requestLogger() {
144
696
  var duration = Date.now() - entryTime;
145
697
  var status = res.statusCode;
146
698
  var level = status >= 500 ? 'error' : status >= 400 ? 'important' : 'info';
147
- _write(level, sessionId, '← ' + method + ' ' + url + ' ' + status + ' ' + duration + 'ms');
699
+ _write(level, referenceId, '← ' + method + ' ' + url + ' ' + status + ' ' + duration + 'ms');
148
700
  };
149
701
 
150
702
  next();
151
703
  };
152
704
  }
153
705
 
154
- function _generateSessionId() {
155
- return 'sid-' + Math.random().toString(36).slice(2, 10);
156
- }
157
-
158
706
  /**
159
- * Purge log files older than retentionDays.
707
+ * Delete this app's log files whose last write was more than retentionDays ago.
708
+ *
709
+ * THE retention policy for everything this package writes — dated app files
710
+ * and session files alike. Nothing outside can do it: whatever is capturing
711
+ * stdout rotates its own copy and has never heard of these.
712
+ *
713
+ * BY MTIME, NOT BY THE NAME. Right for both shapes: the app file's mtime is
714
+ * its last line, and a session file's is when that run last did anything — so
715
+ * a run that finished forty days ago ages out correctly despite having no date
716
+ * in its filename.
717
+ *
718
+ * ONLY THIS APP'S FILES. The previous version deleted every *.log in logDir,
719
+ * so pointing two apps at one directory — or at a directory holding anything
720
+ * else — meant each of them quietly eating the others' history.
721
+ *
722
+ * ASYNC, because it is O(files): a readdir plus a stat each. The synchronous
723
+ * version was fine at a few hundred files and a visible stall at tens of
724
+ * thousands, which is exactly what a per-run file count grows into.
160
725
  */
161
- function purge(retentionDays) {
726
+ async function purge(retentionDays) {
162
727
  var dir = path.resolve(_config.logDir);
163
- if (!fs.existsSync(dir)) return 0;
728
+ var fsp = fs.promises;
164
729
 
165
- var cutoff = Date.now() - (retentionDays || 30) * 86400000;
166
- var files = fs.readdirSync(dir).filter(function(f) { return f.endsWith('.log'); });
167
- var purged = 0;
730
+ var names;
731
+ try { names = await fsp.readdir(dir); }
732
+ catch (e) { return 0; } // no directory means nothing to purge
168
733
 
169
- files.forEach(function(file) {
170
- var filePath = path.join(dir, file);
171
- var stat = fs.statSync(filePath);
172
- if (stat.mtimeMs < cutoff) {
173
- fs.unlinkSync(filePath);
174
- purged++;
175
- }
734
+ var cutoff = Date.now() - (retentionDays || _config.retentionDays || 30) * 86400000;
735
+ var mine = names.filter(function(f) {
736
+ if (!f.endsWith('.log')) return false;
737
+ return f.indexOf(_config.appName + '-') === 0 || f.indexOf(_config.appName + '.') === 0;
176
738
  });
177
739
 
740
+ var purged = 0;
741
+ for (var i = 0; i < mine.length; i++) {
742
+ var filePath = path.join(dir, mine[i]);
743
+ try {
744
+ var stat = await fsp.stat(filePath);
745
+ if (stat.mtimeMs < cutoff) { await fsp.unlink(filePath); purged++; }
746
+ } catch (e) {
747
+ // Gone already, or not ours to delete. Either way not worth failing over.
748
+ }
749
+ }
178
750
  return purged;
179
751
  }
180
752
 
181
753
  /**
182
- * Destroy all open streams (for graceful shutdown).
754
+ * Keep purging, on the app's say-so.
755
+ *
756
+ * OFF unless `retentionDays` is configured, because deletion is destructive
757
+ * and a package should never quietly start removing an app's data because it
758
+ * was upgraded. Set it and this package handles retention itself — no host
759
+ * scheduler required, which is the point: it has to work the same whether it
760
+ * is running under PM2, in a container, or as a bare process.
761
+ *
762
+ * Hourly, not daily: a daily timer in a process restarted every few hours
763
+ * never fires at all.
183
764
  */
765
+ function _schedulePurge() {
766
+ if (_purgeTimer) { clearInterval(_purgeTimer); _purgeTimer = null; }
767
+ if (!_config.retentionDays) return;
768
+
769
+ _purgeTimer = setInterval(function() {
770
+ purge(_config.retentionDays).catch(function() {});
771
+ }, 3600000);
772
+ // Never hold the process open for housekeeping.
773
+ if (_purgeTimer.unref) _purgeTimer.unref();
774
+ }
775
+
776
+ /** Flush and stop. Await it in a shutdown handler. */
184
777
  function destroy() {
185
- Object.keys(_streams).forEach(function(key) {
186
- _streams[key].end();
187
- });
188
- _streams = {};
778
+ if (_timer) { clearTimeout(_timer); _timer = null; }
779
+ if (_purgeTimer) { clearInterval(_purgeTimer); _purgeTimer = null; }
780
+ return flush();
189
781
  }
190
782
 
191
783
  module.exports = {
@@ -193,6 +785,7 @@ module.exports = {
193
785
  createLogger,
194
786
  requestLogger,
195
787
  purge,
788
+ flush,
196
789
  destroy,
197
790
  LEVELS
198
791
  };
package/package.json CHANGED
@@ -1,13 +1,27 @@
1
1
  {
2
2
  "name": "@xeplr/logs",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Text logger with 5 levels, daily rotation, session IDs, and email alerts",
5
5
  "main": "index.js",
6
- "files": ["index.js", "lib/"],
7
- "keywords": ["logs", "logging", "session", "text", "rotation"],
6
+ "files": [
7
+ "index.js",
8
+ "lib/"
9
+ ],
10
+ "keywords": [
11
+ "logs",
12
+ "logging",
13
+ "session",
14
+ "text",
15
+ "rotation"
16
+ ],
8
17
  "author": "xeplr",
9
18
  "license": "MIT",
10
- "repository": { "type": "git", "url": "https://github.com/Xeplr/xeplr-logs" },
11
- "publishConfig": { "access": "public" },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/Xeplr/xeplr-logs"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
12
26
  "dependencies": {}
13
27
  }