@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.
- package/index.js +1 -0
- package/lib/logger.js +679 -86
- package/package.json +19 -5
package/index.js
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
var
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
if (
|
|
55
|
-
var
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
418
|
+
return _pending;
|
|
63
419
|
}
|
|
64
420
|
|
|
65
|
-
function
|
|
66
|
-
var
|
|
67
|
-
var
|
|
68
|
-
|
|
69
|
-
var
|
|
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
|
-
|
|
72
|
-
if (metadata) {
|
|
73
|
-
line += ' ' + (typeof metadata === 'string' ? metadata : JSON.stringify(metadata));
|
|
456
|
+
files[file] = (files[file] || '') + text + '\n';
|
|
74
457
|
}
|
|
75
458
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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,
|
|
94
|
-
var subject = levelLabel + ': [' + _config.appName + '] [' +
|
|
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>
|
|
527
|
+
+ '<p><strong>Reference:</strong> ' + referenceId + '</p>'
|
|
98
528
|
+ '<p><strong>Message:</strong> ' + message + '</p>'
|
|
99
|
-
+ (metadata ? '<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
|
-
*
|
|
110
|
-
*
|
|
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(
|
|
113
|
-
var
|
|
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:
|
|
116
|
-
info:
|
|
117
|
-
important:
|
|
118
|
-
error:
|
|
119
|
-
critical:
|
|
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
|
-
*
|
|
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
|
|
130
|
-
req.
|
|
131
|
-
req.
|
|
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,
|
|
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
|
-
*
|
|
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
|
-
|
|
728
|
+
var fsp = fs.promises;
|
|
164
729
|
|
|
165
|
-
var
|
|
166
|
-
|
|
167
|
-
|
|
730
|
+
var names;
|
|
731
|
+
try { names = await fsp.readdir(dir); }
|
|
732
|
+
catch (e) { return 0; } // no directory means nothing to purge
|
|
168
733
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
186
|
-
|
|
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.
|
|
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": [
|
|
7
|
-
|
|
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": {
|
|
11
|
-
|
|
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
|
}
|