millas 0.2.6 → 0.2.7
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/package.json +1 -1
- package/src/admin/views/layouts/base.njk +1 -1
- package/src/index.js +23 -10
- package/src/logger/Logger.js +341 -0
- package/src/logger/channels/ConsoleChannel.js +39 -0
- package/src/logger/channels/FileChannel.js +101 -0
- package/src/logger/channels/index.js +48 -0
- package/src/logger/formatters/JsonFormatter.js +52 -0
- package/src/logger/formatters/PrettyFormatter.js +95 -0
- package/src/logger/formatters/SimpleFormatter.js +37 -0
- package/src/logger/index.js +69 -0
- package/src/logger/levels.js +52 -0
- package/src/middleware/LogMiddleware.js +54 -28
- package/src/providers/LogServiceProvider.js +138 -0
- package/src/scaffold/templates.js +37 -26
package/package.json
CHANGED
|
@@ -1137,7 +1137,7 @@
|
|
|
1137
1137
|
const prefix = '{{ adminPrefix }}';
|
|
1138
1138
|
const resources = [
|
|
1139
1139
|
{% for r in resources %}
|
|
1140
|
-
{ slug: '{{ r.slug }}', index: {{ r.index }} }{% if not loop.last %},{% endif %}
|
|
1140
|
+
{ slug: '{{ r.slug }}', index: {{ r.index | default('null')}} }{% if not loop.last %},{% endif %}
|
|
1141
1141
|
{% endfor %}
|
|
1142
1142
|
];
|
|
1143
1143
|
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// ── Logger ────────────────────────────────────────────────────────
|
|
4
|
+
const {
|
|
5
|
+
Log,
|
|
6
|
+
Logger,
|
|
7
|
+
LEVELS,
|
|
8
|
+
LEVEL_NAMES,
|
|
9
|
+
PrettyFormatter,
|
|
10
|
+
JsonFormatter,
|
|
11
|
+
SimpleFormatter,
|
|
12
|
+
ConsoleChannel,
|
|
13
|
+
FileChannel,
|
|
14
|
+
NullChannel,
|
|
15
|
+
StackChannel,
|
|
16
|
+
} = require('./logger/index');
|
|
17
|
+
const LogServiceProvider = require('./providers/LogServiceProvider');
|
|
18
|
+
|
|
3
19
|
// ── HTTP Layer ────────────────────────────────────────────────────
|
|
4
20
|
const Controller = require('./controller/Controller');
|
|
5
21
|
const Middleware = require('./middleware/Middleware');
|
|
@@ -16,13 +32,8 @@ const ServiceProvider = require('./providers/ServiceProvider');
|
|
|
16
32
|
const ProviderRegistry = require('./providers/ProviderRegistry');
|
|
17
33
|
|
|
18
34
|
// ── ORM ───────────────────────────────────────────────────────────
|
|
19
|
-
const {
|
|
20
|
-
|
|
21
|
-
SchemaBuilder, MigrationRunner, ModelInspector,
|
|
22
|
-
Q, LookupParser,
|
|
23
|
-
Sum, Avg, Min, Max, Count, AggregateExpression,
|
|
24
|
-
HasOne, HasMany, BelongsTo, BelongsToMany,
|
|
25
|
-
} = require('./orm');
|
|
35
|
+
const { Model, fields, QueryBuilder, DatabaseManager,
|
|
36
|
+
SchemaBuilder, MigrationRunner, ModelInspector } = require('./orm');
|
|
26
37
|
const DatabaseServiceProvider = require('./providers/DatabaseServiceProvider');
|
|
27
38
|
|
|
28
39
|
// ── Auth ──────────────────────────────────────────────────────────
|
|
@@ -65,6 +76,11 @@ const Storage = require('./storage/Storage');
|
|
|
65
76
|
const LocalDriver = require('./storage/drivers/LocalDriver');
|
|
66
77
|
|
|
67
78
|
module.exports = {
|
|
79
|
+
// Logger
|
|
80
|
+
Log, Logger, LEVELS, LEVEL_NAMES,
|
|
81
|
+
PrettyFormatter, JsonFormatter, SimpleFormatter,
|
|
82
|
+
ConsoleChannel, FileChannel, NullChannel, StackChannel,
|
|
83
|
+
LogServiceProvider,
|
|
68
84
|
// HTTP
|
|
69
85
|
Controller, Middleware, MiddlewarePipeline,
|
|
70
86
|
CorsMiddleware, ThrottleMiddleware, LogMiddleware, HttpError,
|
|
@@ -73,9 +89,6 @@ module.exports = {
|
|
|
73
89
|
// ORM
|
|
74
90
|
Model, fields, QueryBuilder, DatabaseManager, SchemaBuilder,
|
|
75
91
|
MigrationRunner, ModelInspector, DatabaseServiceProvider,
|
|
76
|
-
Q, LookupParser,
|
|
77
|
-
Sum, Avg, Min, Max, Count, AggregateExpression,
|
|
78
|
-
HasOne, HasMany, BelongsTo, BelongsToMany,
|
|
79
92
|
// Auth
|
|
80
93
|
Auth, Hasher, JwtDriver, AuthMiddleware, RoleMiddleware,
|
|
81
94
|
AuthController, AuthServiceProvider,
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { LEVELS } = require('./levels');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Logger
|
|
7
|
+
*
|
|
8
|
+
* The Millas application logger. Inspired by Android's Timber library —
|
|
9
|
+
* a small, extensible logging tree where you plant channels ("trees")
|
|
10
|
+
* and log through a unified facade.
|
|
11
|
+
*
|
|
12
|
+
* ── Quick start ────────────────────────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* const { Log } = require('millas');
|
|
15
|
+
*
|
|
16
|
+
* Log.i('Server started on port 3000');
|
|
17
|
+
* Log.d('QueryBuilder', 'SELECT * FROM users', { duration: '4ms' });
|
|
18
|
+
* Log.w('Auth', 'Token expiring soon', { userId: 5 });
|
|
19
|
+
* Log.e('Database', 'Connection failed', error);
|
|
20
|
+
* Log.wtf('Payment', 'Stripe returned null transaction');
|
|
21
|
+
*
|
|
22
|
+
* ── Tag chaining (like Timber.tag()) ───────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* Log.tag('UserService').i('User created', { id: 5 });
|
|
25
|
+
* Log.tag('Mailer').d('Sending email', { to: 'a@b.com' });
|
|
26
|
+
*
|
|
27
|
+
* ── Full signatures ────────────────────────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* Log.v(message)
|
|
30
|
+
* Log.v(tag, message)
|
|
31
|
+
* Log.v(tag, message, context) // context = object | primitive
|
|
32
|
+
* Log.v(tag, message, error) // error = Error instance
|
|
33
|
+
* Log.v(tag, message, context, error)
|
|
34
|
+
*
|
|
35
|
+
* ── Configuration ──────────────────────────────────────────────────────────
|
|
36
|
+
*
|
|
37
|
+
* Log.configure({
|
|
38
|
+
* minLevel: LEVELS.INFO, // filter out VERBOSE + DEBUG in production
|
|
39
|
+
* channel: new StackChannel([
|
|
40
|
+
* new ConsoleChannel({ formatter: new PrettyFormatter() }),
|
|
41
|
+
* new FileChannel({ formatter: new SimpleFormatter(), minLevel: LEVELS.WARN }),
|
|
42
|
+
* ]),
|
|
43
|
+
* });
|
|
44
|
+
*
|
|
45
|
+
* ── Request logging (Django-style) ─────────────────────────────────────────
|
|
46
|
+
*
|
|
47
|
+
* // In bootstrap/app.js — installed automatically by LogServiceProvider
|
|
48
|
+
* app.use(Log.requestMiddleware());
|
|
49
|
+
* // Logs: [2026-03-15 12:00:00] I HTTP POST /api/users 201 14ms
|
|
50
|
+
*/
|
|
51
|
+
class Logger {
|
|
52
|
+
constructor() {
|
|
53
|
+
this._channel = null; // primary channel (StackChannel in production)
|
|
54
|
+
this._minLevel = LEVELS.DEBUG;
|
|
55
|
+
this._tag = null; // set by .tag() for a single chained call
|
|
56
|
+
this._defaultTag = 'App';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Configuration ────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Configure the logger.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} options
|
|
65
|
+
* @param {object} [options.channel] — a channel or StackChannel instance
|
|
66
|
+
* @param {number} [options.minLevel] — minimum level to emit (LEVELS.*)
|
|
67
|
+
* @param {string} [options.defaultTag] — fallback tag when none supplied
|
|
68
|
+
*/
|
|
69
|
+
configure(options = {}) {
|
|
70
|
+
if (options.channel !== undefined) this._channel = options.channel;
|
|
71
|
+
if (options.minLevel !== undefined) this._minLevel = options.minLevel;
|
|
72
|
+
if (options.defaultTag !== undefined) this._defaultTag = options.defaultTag;
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Set a one-shot tag for the next log call.
|
|
78
|
+
* Returns a proxy that resets the tag after the call.
|
|
79
|
+
*
|
|
80
|
+
* Log.tag('UserService').i('Created user', { id: 5 });
|
|
81
|
+
*/
|
|
82
|
+
tag(name) {
|
|
83
|
+
// Return a lightweight proxy that carries the tag
|
|
84
|
+
return new TaggedLogger(this, name);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Timber-style level methods ───────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/** VERBOSE — very detailed tracing, usually disabled in production */
|
|
90
|
+
v(...args) { return this._log(LEVELS.VERBOSE, null, ...args); }
|
|
91
|
+
|
|
92
|
+
/** DEBUG — development diagnostics */
|
|
93
|
+
d(...args) { return this._log(LEVELS.DEBUG, null, ...args); }
|
|
94
|
+
|
|
95
|
+
/** INFO — normal operational messages */
|
|
96
|
+
i(...args) { return this._log(LEVELS.INFO, null, ...args); }
|
|
97
|
+
|
|
98
|
+
/** WARN — unexpected but recoverable */
|
|
99
|
+
w(...args) { return this._log(LEVELS.WARN, null, ...args); }
|
|
100
|
+
|
|
101
|
+
/** ERROR — something failed */
|
|
102
|
+
e(...args) { return this._log(LEVELS.ERROR, null, ...args); }
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* WTF — "What a Terrible Failure"
|
|
106
|
+
* A condition that should NEVER happen. Always logged regardless of minLevel.
|
|
107
|
+
* Triggers a big visual warning in the console.
|
|
108
|
+
*/
|
|
109
|
+
wtf(...args) { return this._log(LEVELS.WTF, null, ...args); }
|
|
110
|
+
|
|
111
|
+
// ─── Verbose aliases (for readability) ────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/** Alias for i() */
|
|
114
|
+
info(...args) { return this.i(...args); }
|
|
115
|
+
/** Alias for d() */
|
|
116
|
+
debug(...args) { return this.d(...args); }
|
|
117
|
+
/** Alias for w() */
|
|
118
|
+
warn(...args) { return this.w(...args); }
|
|
119
|
+
/** Alias for e() */
|
|
120
|
+
error(...args) { return this.e(...args); }
|
|
121
|
+
/** Alias for v() */
|
|
122
|
+
verbose(...args) { return this.v(...args); }
|
|
123
|
+
|
|
124
|
+
// ─── Request middleware (Django-style) ────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Returns an Express middleware that logs every HTTP request.
|
|
128
|
+
*
|
|
129
|
+
* Log format:
|
|
130
|
+
* POST /api/users 201 14ms (coloured by status)
|
|
131
|
+
*
|
|
132
|
+
* @param {object} [options]
|
|
133
|
+
* @param {boolean} [options.includeQuery=false] — append ?query to path
|
|
134
|
+
* @param {boolean} [options.includeBody=false] — log request body (be careful with PII)
|
|
135
|
+
* @param {number} [options.slowThreshold=1000] — warn if response > Nms
|
|
136
|
+
* @param {Function} [options.skip] — (req, res) => bool — skip certain routes
|
|
137
|
+
*/
|
|
138
|
+
requestMiddleware(options = {}) {
|
|
139
|
+
const self = this;
|
|
140
|
+
const {
|
|
141
|
+
includeQuery = false,
|
|
142
|
+
includeBody = false,
|
|
143
|
+
slowThreshold = 1000,
|
|
144
|
+
skip,
|
|
145
|
+
} = options;
|
|
146
|
+
|
|
147
|
+
return function millaRequestLogger(req, res, next) {
|
|
148
|
+
if (typeof skip === 'function' && skip(req, res)) return next();
|
|
149
|
+
|
|
150
|
+
const start = Date.now();
|
|
151
|
+
|
|
152
|
+
res.on('finish', () => {
|
|
153
|
+
const ms = Date.now() - start;
|
|
154
|
+
const status = res.statusCode;
|
|
155
|
+
const method = req.method;
|
|
156
|
+
let url = req.path || req.url || '/';
|
|
157
|
+
|
|
158
|
+
if (includeQuery && req.url && req.url.includes('?')) {
|
|
159
|
+
url = req.url;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Determine log level from status code — mirrors Django's request logging
|
|
163
|
+
let level;
|
|
164
|
+
if (status >= 500) level = LEVELS.ERROR;
|
|
165
|
+
else if (status >= 400) level = LEVELS.WARN;
|
|
166
|
+
else if (ms > slowThreshold) level = LEVELS.WARN;
|
|
167
|
+
else level = LEVELS.INFO;
|
|
168
|
+
|
|
169
|
+
const ctx = {
|
|
170
|
+
method,
|
|
171
|
+
status,
|
|
172
|
+
ms,
|
|
173
|
+
ip: req.ip || req.connection?.remoteAddress,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
if (includeBody && req.body && Object.keys(req.body).length) {
|
|
177
|
+
ctx.body = req.body;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (ms > slowThreshold) {
|
|
181
|
+
ctx.slow = true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
self._log(level, 'HTTP', `${method} ${url} ${status} ${ms}ms`, ctx);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
next();
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── Timer utility ────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Start a named timer. Returns a function that logs the elapsed time.
|
|
195
|
+
*
|
|
196
|
+
* const done = Log.time('Database query');
|
|
197
|
+
* await db.query(...);
|
|
198
|
+
* done(); // → I Timer Database query: 42ms
|
|
199
|
+
*/
|
|
200
|
+
time(label) {
|
|
201
|
+
const start = Date.now();
|
|
202
|
+
return (extraTag) => {
|
|
203
|
+
const ms = Date.now() - start;
|
|
204
|
+
this._log(LEVELS.DEBUG, extraTag || 'Timer', `${label}: ${ms}ms`, { ms });
|
|
205
|
+
return ms;
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Wrap an async function and log its execution time.
|
|
211
|
+
*
|
|
212
|
+
* const result = await Log.timed('fetchUsers', () => User.all());
|
|
213
|
+
*/
|
|
214
|
+
async timed(label, fn, tag) {
|
|
215
|
+
const done = this.time(label);
|
|
216
|
+
try {
|
|
217
|
+
const result = await fn();
|
|
218
|
+
done(tag);
|
|
219
|
+
return result;
|
|
220
|
+
} catch (err) {
|
|
221
|
+
this._log(LEVELS.ERROR, tag || 'Timer', `${label} threw`, err);
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─── Internal ─────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Core dispatch method.
|
|
230
|
+
*
|
|
231
|
+
* Signatures:
|
|
232
|
+
* _log(level, forcedTag, message)
|
|
233
|
+
* _log(level, forcedTag, tag, message)
|
|
234
|
+
* _log(level, forcedTag, tag, message, context)
|
|
235
|
+
* _log(level, forcedTag, tag, message, context, error)
|
|
236
|
+
* _log(level, forcedTag, tag, message, error) // Error as 4th arg
|
|
237
|
+
*/
|
|
238
|
+
_log(level, forcedTag, ...args) {
|
|
239
|
+
// WTF is always emitted regardless of minLevel
|
|
240
|
+
if (level !== LEVELS.WTF && level < this._minLevel) return;
|
|
241
|
+
|
|
242
|
+
const entry = this._parse(level, forcedTag, args);
|
|
243
|
+
this._emit(entry);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_parse(level, forcedTag, args) {
|
|
247
|
+
let tag, message, context, error;
|
|
248
|
+
|
|
249
|
+
// Normalise arguments
|
|
250
|
+
if (args.length === 0) {
|
|
251
|
+
message = '';
|
|
252
|
+
} else if (args.length === 1) {
|
|
253
|
+
// Single arg — could be a string message or an Error
|
|
254
|
+
if (args[0] instanceof Error) {
|
|
255
|
+
error = args[0];
|
|
256
|
+
message = error.message;
|
|
257
|
+
} else {
|
|
258
|
+
message = String(args[0]);
|
|
259
|
+
}
|
|
260
|
+
} else if (args.length === 2) {
|
|
261
|
+
// (tag, message) OR (message, context/error)
|
|
262
|
+
if (typeof args[0] === 'string' && typeof args[1] === 'string') {
|
|
263
|
+
// Both strings: tag + message
|
|
264
|
+
tag = args[0];
|
|
265
|
+
message = args[1];
|
|
266
|
+
} else if (args[1] instanceof Error) {
|
|
267
|
+
message = String(args[0]);
|
|
268
|
+
error = args[1];
|
|
269
|
+
} else if (typeof args[0] === 'string') {
|
|
270
|
+
message = args[0];
|
|
271
|
+
context = args[1];
|
|
272
|
+
} else {
|
|
273
|
+
message = String(args[0]);
|
|
274
|
+
context = args[1];
|
|
275
|
+
}
|
|
276
|
+
} else if (args.length === 3) {
|
|
277
|
+
// (tag, message, context/error)
|
|
278
|
+
tag = String(args[0]);
|
|
279
|
+
message = String(args[1]);
|
|
280
|
+
if (args[2] instanceof Error) error = args[2];
|
|
281
|
+
else context = args[2];
|
|
282
|
+
} else {
|
|
283
|
+
// (tag, message, context, error)
|
|
284
|
+
tag = String(args[0]);
|
|
285
|
+
message = String(args[1]);
|
|
286
|
+
context = args[2];
|
|
287
|
+
error = args[3] instanceof Error ? args[3] : undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
level,
|
|
292
|
+
tag: forcedTag || tag || this._defaultTag,
|
|
293
|
+
message: message || '',
|
|
294
|
+
context,
|
|
295
|
+
error,
|
|
296
|
+
timestamp: new Date().toISOString(),
|
|
297
|
+
pid: process.pid,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
_emit(entry) {
|
|
302
|
+
if (!this._channel) {
|
|
303
|
+
// No channel configured — fall back to raw console so nothing is lost
|
|
304
|
+
const prefix = `[${entry.level}] ${entry.tag}: ${entry.message}`;
|
|
305
|
+
if (entry.level >= 4) process.stderr.write(prefix + '\n');
|
|
306
|
+
else process.stdout.write(prefix + '\n');
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
this._channel.write(entry);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
// Never crash the app because of a logging failure
|
|
314
|
+
process.stderr.write(`[millas logger] channel error: ${err.message}\n`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── TaggedLogger — returned by Log.tag() ─────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
class TaggedLogger {
|
|
322
|
+
constructor(logger, tag) {
|
|
323
|
+
this._logger = logger;
|
|
324
|
+
this._tag = tag;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
v(...args) { return this._logger._log(LEVELS.VERBOSE, this._tag, ...args); }
|
|
328
|
+
d(...args) { return this._logger._log(LEVELS.DEBUG, this._tag, ...args); }
|
|
329
|
+
i(...args) { return this._logger._log(LEVELS.INFO, this._tag, ...args); }
|
|
330
|
+
w(...args) { return this._logger._log(LEVELS.WARN, this._tag, ...args); }
|
|
331
|
+
e(...args) { return this._logger._log(LEVELS.ERROR, this._tag, ...args); }
|
|
332
|
+
wtf(...args) { return this._logger._log(LEVELS.WTF, this._tag, ...args); }
|
|
333
|
+
|
|
334
|
+
info(...args) { return this.i(...args); }
|
|
335
|
+
debug(...args) { return this.d(...args); }
|
|
336
|
+
warn(...args) { return this.w(...args); }
|
|
337
|
+
error(...args) { return this.e(...args); }
|
|
338
|
+
verbose(...args) { return this.v(...args); }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = Logger;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ConsoleChannel
|
|
5
|
+
*
|
|
6
|
+
* Writes log entries to process.stdout (levels < ERROR) or
|
|
7
|
+
* process.stderr (ERROR and WTF).
|
|
8
|
+
*
|
|
9
|
+
* Usage in config/logging.js:
|
|
10
|
+
* new ConsoleChannel({ formatter: new PrettyFormatter() })
|
|
11
|
+
*/
|
|
12
|
+
class ConsoleChannel {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} options
|
|
15
|
+
* @param {object} options.formatter — formatter instance (PrettyFormatter, JsonFormatter, …)
|
|
16
|
+
* @param {number} [options.minLevel] — minimum level to output (default: 0 = all)
|
|
17
|
+
*/
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.formatter = options.formatter;
|
|
20
|
+
this.minLevel = options.minLevel ?? 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
write(entry) {
|
|
24
|
+
if (entry.level < this.minLevel) return;
|
|
25
|
+
|
|
26
|
+
const output = this.formatter
|
|
27
|
+
? this.formatter.format(entry)
|
|
28
|
+
: `[${entry.level}] ${entry.message}`;
|
|
29
|
+
|
|
30
|
+
// Route errors to stderr
|
|
31
|
+
if (entry.level >= 4) {
|
|
32
|
+
process.stderr.write(output + '\n');
|
|
33
|
+
} else {
|
|
34
|
+
process.stdout.write(output + '\n');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = ConsoleChannel;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* FileChannel
|
|
8
|
+
*
|
|
9
|
+
* Writes log entries to daily rotating files.
|
|
10
|
+
* storage/logs/millas-2026-03-15.log
|
|
11
|
+
* storage/logs/millas-2026-03-16.log
|
|
12
|
+
* …
|
|
13
|
+
*
|
|
14
|
+
* Uses Node's built-in fs — no external log-rotation library needed.
|
|
15
|
+
* Files are appended synchronously to avoid losing entries on crash.
|
|
16
|
+
*
|
|
17
|
+
* Usage in config/logging.js:
|
|
18
|
+
* new FileChannel({
|
|
19
|
+
* path: 'storage/logs',
|
|
20
|
+
* prefix: 'millas',
|
|
21
|
+
* formatter: new SimpleFormatter(),
|
|
22
|
+
* minLevel: LEVELS.INFO,
|
|
23
|
+
* maxFiles: 30, // keep 30 days of logs
|
|
24
|
+
* })
|
|
25
|
+
*/
|
|
26
|
+
class FileChannel {
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} options
|
|
29
|
+
* @param {string} [options.path] — directory path (default: storage/logs)
|
|
30
|
+
* @param {string} [options.prefix] — filename prefix (default: 'millas')
|
|
31
|
+
* @param {object} [options.formatter] — formatter instance
|
|
32
|
+
* @param {number} [options.minLevel] — minimum level (default: 0)
|
|
33
|
+
* @param {number} [options.maxFiles] — max daily files to retain (default: 30)
|
|
34
|
+
*/
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
this._dir = path.resolve(process.cwd(), options.path || 'storage/logs');
|
|
37
|
+
this._prefix = options.prefix || 'millas';
|
|
38
|
+
this.formatter = options.formatter;
|
|
39
|
+
this.minLevel = options.minLevel ?? 0;
|
|
40
|
+
this._maxFiles = options.maxFiles ?? 30;
|
|
41
|
+
this._lastDate = null;
|
|
42
|
+
this._stream = null;
|
|
43
|
+
this._ensuredDir = false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
write(entry) {
|
|
47
|
+
if (entry.level < this.minLevel) return;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
this._ensureDir();
|
|
51
|
+
|
|
52
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
53
|
+
if (today !== this._lastDate) {
|
|
54
|
+
this._rotateStream(today);
|
|
55
|
+
this._pruneOldFiles();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const line = this.formatter
|
|
59
|
+
? this.formatter.format(entry)
|
|
60
|
+
: `[${entry.timestamp}] [${entry.level}] ${entry.message}`;
|
|
61
|
+
|
|
62
|
+
fs.appendFileSync(this._currentPath, line + '\n', 'utf8');
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// Never crash the app because of a logging failure
|
|
65
|
+
process.stderr.write(`[millas logger] FileChannel write error: ${err.message}\n`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── Internals ────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
_ensureDir() {
|
|
72
|
+
if (this._ensuredDir) return;
|
|
73
|
+
fs.mkdirSync(this._dir, { recursive: true });
|
|
74
|
+
this._ensuredDir = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_rotateStream(date) {
|
|
78
|
+
this._lastDate = date;
|
|
79
|
+
this._currentPath = path.join(this._dir, `${this._prefix}-${date}.log`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_pruneOldFiles() {
|
|
83
|
+
try {
|
|
84
|
+
const files = fs.readdirSync(this._dir)
|
|
85
|
+
.filter(f => f.startsWith(this._prefix + '-') && f.endsWith('.log'))
|
|
86
|
+
.sort(); // ISO date prefix sorts chronologically
|
|
87
|
+
|
|
88
|
+
const toDelete = files.slice(0, Math.max(0, files.length - this._maxFiles));
|
|
89
|
+
for (const f of toDelete) {
|
|
90
|
+
try { fs.unlinkSync(path.join(this._dir, f)); } catch {}
|
|
91
|
+
}
|
|
92
|
+
} catch {}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Current log file path (useful for tooling). */
|
|
96
|
+
get currentFile() {
|
|
97
|
+
return this._currentPath || null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = FileChannel;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NullChannel
|
|
5
|
+
*
|
|
6
|
+
* Silently discards all log entries.
|
|
7
|
+
* Useful in tests where you want to suppress all output.
|
|
8
|
+
*
|
|
9
|
+
* Log.configure({ channels: [new NullChannel()] });
|
|
10
|
+
*/
|
|
11
|
+
class NullChannel {
|
|
12
|
+
write() { /* intentionally empty */ }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* StackChannel
|
|
17
|
+
*
|
|
18
|
+
* Fans a single log entry out to multiple channels simultaneously.
|
|
19
|
+
* This is the standard "stack" pattern — one channel for console,
|
|
20
|
+
* another for file, optionally one for an external service.
|
|
21
|
+
*
|
|
22
|
+
* new StackChannel([
|
|
23
|
+
* new ConsoleChannel({ formatter: new PrettyFormatter() }),
|
|
24
|
+
* new FileChannel({ formatter: new SimpleFormatter(), minLevel: LEVELS.INFO }),
|
|
25
|
+
* ])
|
|
26
|
+
*/
|
|
27
|
+
class StackChannel {
|
|
28
|
+
/**
|
|
29
|
+
* @param {Array} channels — array of channel instances
|
|
30
|
+
*/
|
|
31
|
+
constructor(channels = []) {
|
|
32
|
+
this._channels = channels;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Add a channel at runtime. */
|
|
36
|
+
add(channel) {
|
|
37
|
+
this._channels.push(channel);
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
write(entry) {
|
|
42
|
+
for (const ch of this._channels) {
|
|
43
|
+
try { ch.write(entry); } catch {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { NullChannel, StackChannel };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { LEVEL_NAMES } = require('../levels');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* JsonFormatter
|
|
7
|
+
*
|
|
8
|
+
* Emits one JSON object per log entry — ideal for production environments
|
|
9
|
+
* where logs are shipped to Datadog, Elasticsearch, CloudWatch, etc.
|
|
10
|
+
*
|
|
11
|
+
* Output (one line per entry):
|
|
12
|
+
* {"ts":"2026-03-15T12:00:00.000Z","level":"INFO","tag":"Auth","msg":"Login","ctx":{...}}
|
|
13
|
+
*/
|
|
14
|
+
class JsonFormatter {
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} options
|
|
17
|
+
* @param {boolean} [options.pretty=false] — pretty-print JSON (for debugging)
|
|
18
|
+
* @param {object} [options.extra] — static fields merged into every entry (e.g. service name)
|
|
19
|
+
*/
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.pretty = options.pretty || false;
|
|
22
|
+
this.extra = options.extra || {};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
format(entry) {
|
|
26
|
+
const { level, tag, message, context, error, timestamp } = entry;
|
|
27
|
+
|
|
28
|
+
const record = {
|
|
29
|
+
ts: timestamp || new Date().toISOString(),
|
|
30
|
+
level: LEVEL_NAMES[level] || String(level),
|
|
31
|
+
...this.extra,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (tag) record.tag = tag;
|
|
35
|
+
record.msg = message;
|
|
36
|
+
if (context !== undefined && context !== null) record.ctx = context;
|
|
37
|
+
|
|
38
|
+
if (error instanceof Error) {
|
|
39
|
+
record.error = {
|
|
40
|
+
message: error.message,
|
|
41
|
+
name: error.name,
|
|
42
|
+
stack: error.stack,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return this.pretty
|
|
47
|
+
? JSON.stringify(record, null, 2)
|
|
48
|
+
: JSON.stringify(record);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = JsonFormatter;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { LEVEL_NAMES, LEVEL_TAGS, LEVEL_COLOURS, RESET, BOLD, DIM } = require('../levels');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PrettyFormatter
|
|
7
|
+
*
|
|
8
|
+
* Colourful, human-readable output. Designed for development.
|
|
9
|
+
* Inspired by Timber (Android) and Laravel's log formatting.
|
|
10
|
+
*
|
|
11
|
+
* Output:
|
|
12
|
+
* [2026-03-15 12:00:00] I UserController User #5 logged in
|
|
13
|
+
* [2026-03-15 12:00:01] E Database Connection refused { host: 'localhost' }
|
|
14
|
+
* [2026-03-15 12:00:02] W Auth Token expiring soon
|
|
15
|
+
*
|
|
16
|
+
* WTF level also prints the full stack trace.
|
|
17
|
+
*/
|
|
18
|
+
class PrettyFormatter {
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} options
|
|
21
|
+
* @param {boolean} [options.timestamp=true] — show timestamp
|
|
22
|
+
* @param {boolean} [options.tag=true] — show tag/component name
|
|
23
|
+
* @param {boolean} [options.colour=true] — ANSI colour (disable for pipes/files)
|
|
24
|
+
* @param {string} [options.timestampFormat] — 'iso' | 'short' (default: 'short')
|
|
25
|
+
*/
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
this.showTimestamp = options.timestamp !== false;
|
|
28
|
+
this.showTag = options.tag !== false;
|
|
29
|
+
this.colour = options.colour !== false;
|
|
30
|
+
this.tsFormat = options.timestampFormat || 'short';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
format(entry) {
|
|
34
|
+
const { level, tag, message, context, error } = entry;
|
|
35
|
+
|
|
36
|
+
const c = this.colour ? LEVEL_COLOURS[level] : '';
|
|
37
|
+
const r = this.colour ? RESET : '';
|
|
38
|
+
const b = this.colour ? BOLD : '';
|
|
39
|
+
const d = this.colour ? '\x1b[2m' : '';
|
|
40
|
+
const lvl = LEVEL_TAGS[level] || '?';
|
|
41
|
+
|
|
42
|
+
const parts = [];
|
|
43
|
+
|
|
44
|
+
// Timestamp
|
|
45
|
+
if (this.showTimestamp) {
|
|
46
|
+
const ts = this._timestamp();
|
|
47
|
+
parts.push(`${d}[${ts}]${r}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Level tag (single letter, coloured)
|
|
51
|
+
parts.push(`${c}${b}${lvl}${r}`);
|
|
52
|
+
|
|
53
|
+
// Component/tag
|
|
54
|
+
if (this.showTag && tag) {
|
|
55
|
+
const tagStr = tag.padEnd(18);
|
|
56
|
+
parts.push(`${b}${tagStr}${r}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Message
|
|
60
|
+
parts.push(`${c}${message}${r}`);
|
|
61
|
+
|
|
62
|
+
// Context object
|
|
63
|
+
if (context !== undefined && context !== null) {
|
|
64
|
+
const ctx = typeof context === 'object'
|
|
65
|
+
? JSON.stringify(context, null, 0)
|
|
66
|
+
: String(context);
|
|
67
|
+
parts.push(`${d}${ctx}${r}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let output = parts.join(' ');
|
|
71
|
+
|
|
72
|
+
// Error stack
|
|
73
|
+
if (error instanceof Error) {
|
|
74
|
+
output += `\n${d}${error.stack || error.message}${r}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// WTF: print big warning banner
|
|
78
|
+
if (level === 5) {
|
|
79
|
+
const banner = this.colour
|
|
80
|
+
? `\x1b[35m\x1b[1m${'━'.repeat(60)}\x1b[0m`
|
|
81
|
+
: '━'.repeat(60);
|
|
82
|
+
output = `${banner}\n${output}\n${banner}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return output;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_timestamp() {
|
|
89
|
+
const now = new Date();
|
|
90
|
+
if (this.tsFormat === 'iso') return now.toISOString();
|
|
91
|
+
return now.toISOString().replace('T', ' ').slice(0, 19);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = PrettyFormatter;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { LEVEL_NAMES } = require('../levels');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SimpleFormatter
|
|
7
|
+
*
|
|
8
|
+
* Plain, no-colour text. Suitable for file output or any sink
|
|
9
|
+
* where ANSI codes would be noise.
|
|
10
|
+
*
|
|
11
|
+
* Output:
|
|
12
|
+
* [2026-03-15 12:00:00] [INFO] Auth: User logged in
|
|
13
|
+
* [2026-03-15 12:00:01] [ERROR] DB: Query failed {"table":"users"}
|
|
14
|
+
*/
|
|
15
|
+
class SimpleFormatter {
|
|
16
|
+
format(entry) {
|
|
17
|
+
const { level, tag, message, context, error, timestamp } = entry;
|
|
18
|
+
|
|
19
|
+
const ts = (timestamp || new Date().toISOString()).replace('T', ' ').slice(0, 23);
|
|
20
|
+
const lvlName = (LEVEL_NAMES[level] || String(level)).padEnd(7);
|
|
21
|
+
const tagPart = tag ? `${tag}: ` : '';
|
|
22
|
+
|
|
23
|
+
let line = `[${ts}] [${lvlName}] ${tagPart}${message}`;
|
|
24
|
+
|
|
25
|
+
if (context !== undefined && context !== null) {
|
|
26
|
+
line += ' ' + (typeof context === 'object' ? JSON.stringify(context) : String(context));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (error instanceof Error) {
|
|
30
|
+
line += '\n ' + (error.stack || error.message).replace(/\n/g, '\n ');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return line;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = SimpleFormatter;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Logger = require('./Logger');
|
|
4
|
+
const { LEVELS, LEVEL_NAMES } = require('./levels');
|
|
5
|
+
const PrettyFormatter = require('./formatters/PrettyFormatter');
|
|
6
|
+
const JsonFormatter = require('./formatters/JsonFormatter');
|
|
7
|
+
const SimpleFormatter = require('./formatters/SimpleFormatter');
|
|
8
|
+
const ConsoleChannel = require('./channels/ConsoleChannel');
|
|
9
|
+
const FileChannel = require('./channels/FileChannel');
|
|
10
|
+
const { NullChannel, StackChannel } = require('./channels/index');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Log
|
|
14
|
+
*
|
|
15
|
+
* The global logger singleton — the one you import everywhere.
|
|
16
|
+
*
|
|
17
|
+
* const { Log } = require('millas');
|
|
18
|
+
*
|
|
19
|
+
* Log.i('App booted');
|
|
20
|
+
* Log.tag('UserService').d('Fetching user', { id: 5 });
|
|
21
|
+
* Log.e('Payment', 'Stripe failed', error);
|
|
22
|
+
* Log.wtf('Impossible state reached');
|
|
23
|
+
*
|
|
24
|
+
* Configured automatically by LogServiceProvider when you add it
|
|
25
|
+
* to your providers list. For manual setup:
|
|
26
|
+
*
|
|
27
|
+
* Log.configure({
|
|
28
|
+
* minLevel: LEVELS.INFO,
|
|
29
|
+
* channel: new StackChannel([
|
|
30
|
+
* new ConsoleChannel({ formatter: new PrettyFormatter() }),
|
|
31
|
+
* new FileChannel({ formatter: new SimpleFormatter(), minLevel: LEVELS.WARN }),
|
|
32
|
+
* ]),
|
|
33
|
+
* });
|
|
34
|
+
*/
|
|
35
|
+
const Log = new Logger();
|
|
36
|
+
|
|
37
|
+
// Apply sensible defaults so Log works out-of-the-box before
|
|
38
|
+
// LogServiceProvider runs (during framework boot, tests, etc.)
|
|
39
|
+
Log.configure({
|
|
40
|
+
minLevel: process.env.NODE_ENV === 'production' ? LEVELS.INFO : LEVELS.DEBUG,
|
|
41
|
+
channel: new ConsoleChannel({
|
|
42
|
+
formatter: new PrettyFormatter({
|
|
43
|
+
colour: process.stdout.isTTY !== false,
|
|
44
|
+
}),
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
// The singleton you use everywhere
|
|
50
|
+
Log,
|
|
51
|
+
|
|
52
|
+
// The class (for constructing named loggers)
|
|
53
|
+
Logger,
|
|
54
|
+
|
|
55
|
+
// Level constants
|
|
56
|
+
LEVELS,
|
|
57
|
+
LEVEL_NAMES,
|
|
58
|
+
|
|
59
|
+
// Formatters
|
|
60
|
+
PrettyFormatter,
|
|
61
|
+
JsonFormatter,
|
|
62
|
+
SimpleFormatter,
|
|
63
|
+
|
|
64
|
+
// Channels
|
|
65
|
+
ConsoleChannel,
|
|
66
|
+
FileChannel,
|
|
67
|
+
NullChannel,
|
|
68
|
+
StackChannel,
|
|
69
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Log levels — ordered by severity (lowest to highest).
|
|
5
|
+
*
|
|
6
|
+
* Inspired by Android's Timber / Log class:
|
|
7
|
+
* VERBOSE — extremely detailed; usually filtered out in production
|
|
8
|
+
* DEBUG — development diagnostics
|
|
9
|
+
* INFO — normal operational messages (default minimum in production)
|
|
10
|
+
* WARN — something unexpected but recoverable
|
|
11
|
+
* ERROR — something failed; needs attention
|
|
12
|
+
* WTF — "What a Terrible Failure" — should never happen; always logged
|
|
13
|
+
*/
|
|
14
|
+
const LEVELS = {
|
|
15
|
+
VERBOSE: 0,
|
|
16
|
+
DEBUG: 1,
|
|
17
|
+
INFO: 2,
|
|
18
|
+
WARN: 3,
|
|
19
|
+
ERROR: 4,
|
|
20
|
+
WTF: 5,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Reverse map: number → name */
|
|
24
|
+
const LEVEL_NAMES = Object.fromEntries(
|
|
25
|
+
Object.entries(LEVELS).map(([k, v]) => [v, k])
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/** Single-letter tags (like Android logcat) */
|
|
29
|
+
const LEVEL_TAGS = {
|
|
30
|
+
0: 'V',
|
|
31
|
+
1: 'D',
|
|
32
|
+
2: 'I',
|
|
33
|
+
3: 'W',
|
|
34
|
+
4: 'E',
|
|
35
|
+
5: 'F', // Fatal / WTF
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** ANSI colour codes for each level */
|
|
39
|
+
const LEVEL_COLOURS = {
|
|
40
|
+
0: '\x1b[90m', // VERBOSE — dark grey
|
|
41
|
+
1: '\x1b[36m', // DEBUG — cyan
|
|
42
|
+
2: '\x1b[32m', // INFO — green
|
|
43
|
+
3: '\x1b[33m', // WARN — yellow
|
|
44
|
+
4: '\x1b[31m', // ERROR — red
|
|
45
|
+
5: '\x1b[35m', // WTF — magenta
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const RESET = '\x1b[0m';
|
|
49
|
+
const BOLD = '\x1b[1m';
|
|
50
|
+
const DIM = '\x1b[2m';
|
|
51
|
+
|
|
52
|
+
module.exports = { LEVELS, LEVEL_NAMES, LEVEL_TAGS, LEVEL_COLOURS, RESET, BOLD, DIM };
|
|
@@ -5,53 +5,79 @@ const Middleware = require('./Middleware');
|
|
|
5
5
|
/**
|
|
6
6
|
* LogMiddleware
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Django-style HTTP request logger.
|
|
9
|
+
* Uses the Millas Log singleton — output goes through your configured channels.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* Log levels per status:
|
|
12
|
+
* 2xx, 3xx → INFO (green)
|
|
13
|
+
* 4xx → WARN (yellow)
|
|
14
|
+
* 5xx → ERROR (red)
|
|
15
|
+
* slow req → WARN (with slow=true in context)
|
|
12
16
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
17
|
+
* Output:
|
|
18
|
+
* I HTTP GET /api/users 200 4ms
|
|
19
|
+
* W HTTP POST /api/login 401 12ms
|
|
20
|
+
* E HTTP GET /api/crash 500 3ms
|
|
21
|
+
*
|
|
22
|
+
* Usage (register as middleware):
|
|
23
|
+
* app.use(new LogMiddleware().handle.bind(new LogMiddleware()));
|
|
24
|
+
*
|
|
25
|
+
* Or use Log.requestMiddleware() directly for more options.
|
|
15
26
|
*
|
|
16
27
|
* Options:
|
|
17
|
-
* silent
|
|
18
|
-
*
|
|
28
|
+
* silent — suppress all output (default: false)
|
|
29
|
+
* includeQuery — include query string in URL (default: false)
|
|
30
|
+
* includeIp — include client IP (default: true)
|
|
31
|
+
* slowThreshold — warn if response > Nms (default: 1000)
|
|
32
|
+
* skip — function(req, res) => bool — skip matching routes
|
|
19
33
|
*/
|
|
20
34
|
class LogMiddleware extends Middleware {
|
|
21
35
|
constructor(options = {}) {
|
|
22
36
|
super();
|
|
23
|
-
this.silent
|
|
24
|
-
this.
|
|
37
|
+
this.silent = options.silent ?? false;
|
|
38
|
+
this.includeQuery = options.includeQuery ?? false;
|
|
39
|
+
this.includeIp = options.includeIp ?? true;
|
|
40
|
+
this.slowThreshold = options.slowThreshold ?? 1000;
|
|
41
|
+
this.skip = options.skip ?? null;
|
|
25
42
|
}
|
|
26
43
|
|
|
27
44
|
async handle(req, res, next) {
|
|
28
45
|
if (this.silent) return next();
|
|
46
|
+
if (typeof this.skip === 'function' && this.skip(req, res)) return next();
|
|
47
|
+
|
|
48
|
+
// Lazy-require Log so this file loads even before LogServiceProvider runs
|
|
49
|
+
let Log;
|
|
50
|
+
try {
|
|
51
|
+
Log = require('../logger/index').Log;
|
|
52
|
+
} catch {
|
|
53
|
+
return next();
|
|
54
|
+
}
|
|
29
55
|
|
|
30
56
|
const start = Date.now();
|
|
31
|
-
const {
|
|
57
|
+
const { LEVELS } = require('../logger/levels');
|
|
32
58
|
|
|
33
59
|
res.on('finish', () => {
|
|
34
60
|
const ms = Date.now() - start;
|
|
35
61
|
const status = res.statusCode;
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
: status < 500 ? '\x1b[33m'
|
|
42
|
-
: '\x1b[31m';
|
|
43
|
-
const reset = '\x1b[0m';
|
|
44
|
-
const methodColor = '\x1b[1m';
|
|
45
|
-
console.log(
|
|
46
|
-
` ${'\x1b[90m'}[${ts}]${reset} ` +
|
|
47
|
-
`${methodColor}${method.padEnd(7)}${reset} ` +
|
|
48
|
-
`${routePath || url} ` +
|
|
49
|
-
`${statusColor}${status}${reset} ` +
|
|
50
|
-
`${'\x1b[90m'}${ms}ms${reset}`
|
|
51
|
-
);
|
|
52
|
-
} else {
|
|
53
|
-
console.log(`[${ts}] ${method} ${routePath || url} ${status} ${ms}ms`);
|
|
62
|
+
const method = req.method;
|
|
63
|
+
let url = req.path || req.url || '/';
|
|
64
|
+
|
|
65
|
+
if (this.includeQuery && req.url && req.url.includes('?')) {
|
|
66
|
+
url = req.url;
|
|
54
67
|
}
|
|
68
|
+
|
|
69
|
+
// Level based on status + response time
|
|
70
|
+
let level;
|
|
71
|
+
if (status >= 500) level = LEVELS.ERROR;
|
|
72
|
+
else if (status >= 400) level = LEVELS.WARN;
|
|
73
|
+
else if (ms > this.slowThreshold) level = LEVELS.WARN;
|
|
74
|
+
else level = LEVELS.INFO;
|
|
75
|
+
|
|
76
|
+
const ctx = { status, ms };
|
|
77
|
+
if (this.includeIp) ctx.ip = req.ip || req.connection?.remoteAddress;
|
|
78
|
+
if (ms > this.slowThreshold) ctx.slow = true;
|
|
79
|
+
|
|
80
|
+
Log._log(level, 'HTTP', `${method} ${url} ${status} ${ms}ms`, ctx);
|
|
55
81
|
});
|
|
56
82
|
|
|
57
83
|
next();
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ServiceProvider = require('./ServiceProvider');
|
|
4
|
+
const {
|
|
5
|
+
Log,
|
|
6
|
+
Logger,
|
|
7
|
+
LEVELS,
|
|
8
|
+
PrettyFormatter,
|
|
9
|
+
JsonFormatter,
|
|
10
|
+
SimpleFormatter,
|
|
11
|
+
ConsoleChannel,
|
|
12
|
+
FileChannel,
|
|
13
|
+
NullChannel,
|
|
14
|
+
StackChannel,
|
|
15
|
+
} = require('../logger/index');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* LogServiceProvider
|
|
19
|
+
*
|
|
20
|
+
* Reads config/logging.js and configures the Log singleton.
|
|
21
|
+
* Also registers Log and Logger in the DI container.
|
|
22
|
+
*
|
|
23
|
+
* Add to bootstrap/app.js:
|
|
24
|
+
* app.providers([LogServiceProvider, ...]);
|
|
25
|
+
*
|
|
26
|
+
* The provider is intentionally ordered first so every other provider
|
|
27
|
+
* can use Log during their boot() method.
|
|
28
|
+
*
|
|
29
|
+
* config/logging.js is optional — sensible defaults apply if absent.
|
|
30
|
+
*/
|
|
31
|
+
class LogServiceProvider extends ServiceProvider {
|
|
32
|
+
register(container) {
|
|
33
|
+
container.instance('Log', Log);
|
|
34
|
+
container.instance('Logger', Logger);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async boot(container, app) {
|
|
38
|
+
let config = {};
|
|
39
|
+
try {
|
|
40
|
+
config = require(process.cwd() + '/config/logging');
|
|
41
|
+
} catch {
|
|
42
|
+
// No config — use defaults already set in logger/index.js
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const channels = this._buildChannels(config);
|
|
47
|
+
|
|
48
|
+
Log.configure({
|
|
49
|
+
minLevel: this._resolveLevel(config.level ?? config.minLevel),
|
|
50
|
+
defaultTag: config.defaultTag || 'App',
|
|
51
|
+
channel: channels.length === 1
|
|
52
|
+
? channels[0]
|
|
53
|
+
: new StackChannel(channels),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
Log.tag('Millas').i(`Logger configured — level: ${this._levelName(Log._minLevel)}, channels: ${channels.length}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Private ──────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
_buildChannels(config) {
|
|
62
|
+
const channelDefs = config.channels || ['console'];
|
|
63
|
+
const built = [];
|
|
64
|
+
|
|
65
|
+
for (const def of channelDefs) {
|
|
66
|
+
// String shorthand: 'console' | 'file' | 'null'
|
|
67
|
+
if (def === 'console' || def?.driver === 'console') {
|
|
68
|
+
built.push(this._buildConsole(def));
|
|
69
|
+
} else if (def === 'file' || def?.driver === 'file') {
|
|
70
|
+
built.push(this._buildFile(def));
|
|
71
|
+
} else if (def === 'null' || def?.driver === 'null') {
|
|
72
|
+
built.push(new NullChannel());
|
|
73
|
+
} else if (def && typeof def.write === 'function') {
|
|
74
|
+
// Already an instantiated channel — use directly
|
|
75
|
+
built.push(def);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Always have at least a console channel
|
|
80
|
+
if (!built.length) built.push(this._buildConsole({}));
|
|
81
|
+
|
|
82
|
+
return built;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_buildConsole(opts = {}) {
|
|
86
|
+
const fmt = this._buildFormatter(opts.formatter || opts.format || 'pretty', opts);
|
|
87
|
+
return new ConsoleChannel({
|
|
88
|
+
formatter: fmt,
|
|
89
|
+
minLevel: this._resolveLevel(opts.level ?? opts.minLevel),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_buildFile(opts = {}) {
|
|
94
|
+
const fmt = this._buildFormatter(opts.formatter || opts.format || 'simple', opts);
|
|
95
|
+
return new FileChannel({
|
|
96
|
+
path: opts.path || 'storage/logs',
|
|
97
|
+
prefix: opts.prefix || 'millas',
|
|
98
|
+
formatter: fmt,
|
|
99
|
+
minLevel: this._resolveLevel(opts.level ?? opts.minLevel),
|
|
100
|
+
maxFiles: opts.maxFiles ?? 30,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_buildFormatter(name, opts = {}) {
|
|
105
|
+
if (name && typeof name === 'object' && typeof name.format === 'function') {
|
|
106
|
+
return name; // already an instance
|
|
107
|
+
}
|
|
108
|
+
switch (String(name).toLowerCase()) {
|
|
109
|
+
case 'json':
|
|
110
|
+
return new JsonFormatter({ extra: opts.extra });
|
|
111
|
+
case 'simple':
|
|
112
|
+
return new SimpleFormatter();
|
|
113
|
+
case 'pretty':
|
|
114
|
+
default:
|
|
115
|
+
return new PrettyFormatter({
|
|
116
|
+
colour: opts.colour !== false && process.stdout.isTTY !== false,
|
|
117
|
+
timestamp: opts.timestamp !== false,
|
|
118
|
+
tag: opts.tag !== false,
|
|
119
|
+
timestampFormat: opts.timestampFormat || 'short',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_resolveLevel(level) {
|
|
125
|
+
if (level === undefined || level === null) {
|
|
126
|
+
return process.env.NODE_ENV === 'production' ? LEVELS.INFO : LEVELS.DEBUG;
|
|
127
|
+
}
|
|
128
|
+
if (typeof level === 'number') return level;
|
|
129
|
+
return LEVELS[String(level).toUpperCase()] ?? LEVELS.DEBUG;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_levelName(n) {
|
|
133
|
+
const names = ['VERBOSE','DEBUG','INFO','WARN','ERROR','WTF'];
|
|
134
|
+
return names[n] || String(n);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = LogServiceProvider;
|
|
@@ -66,8 +66,6 @@ storage/logs/*.log
|
|
|
66
66
|
storage/uploads/*
|
|
67
67
|
!storage/uploads/.gitkeep
|
|
68
68
|
database/database.sqlite
|
|
69
|
-
# Millas migration snapshot — auto-generated, do not commit
|
|
70
|
-
.millas/
|
|
71
69
|
`,
|
|
72
70
|
|
|
73
71
|
// ─── millas.config.js ─────────────────────────────────────────
|
|
@@ -106,6 +104,7 @@ function resolveMillas() {
|
|
|
106
104
|
const {
|
|
107
105
|
Application,
|
|
108
106
|
Admin,
|
|
107
|
+
LogServiceProvider,
|
|
109
108
|
CacheServiceProvider,
|
|
110
109
|
StorageServiceProvider,
|
|
111
110
|
MailServiceProvider,
|
|
@@ -124,6 +123,7 @@ const app = new Application(expressApp);
|
|
|
124
123
|
|
|
125
124
|
// ── Register service providers ──────────────────────────────────
|
|
126
125
|
app.providers([
|
|
126
|
+
LogServiceProvider, // first — so Log works in all other providers
|
|
127
127
|
CacheServiceProvider,
|
|
128
128
|
StorageServiceProvider,
|
|
129
129
|
MailServiceProvider,
|
|
@@ -198,6 +198,38 @@ module.exports = function (Route) {
|
|
|
198
198
|
|
|
199
199
|
});
|
|
200
200
|
};
|
|
201
|
+
`,
|
|
202
|
+
|
|
203
|
+
// ─── config/logging.js ────────────────────────────────────────
|
|
204
|
+
'config/logging.js': `'use strict';
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Logging Configuration
|
|
208
|
+
*
|
|
209
|
+
* Levels (lowest → highest): verbose | debug | info | warn | error | wtf
|
|
210
|
+
* Channels: 'console' | 'file' | 'null'
|
|
211
|
+
* Formats: 'pretty' (coloured) | 'simple' (plain text) | 'json' (structured)
|
|
212
|
+
*/
|
|
213
|
+
module.exports = {
|
|
214
|
+
level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
|
|
215
|
+
|
|
216
|
+
defaultTag: process.env.APP_NAME || 'App',
|
|
217
|
+
|
|
218
|
+
channels: [
|
|
219
|
+
{
|
|
220
|
+
driver: 'console',
|
|
221
|
+
format: 'pretty',
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
driver: 'file',
|
|
225
|
+
format: 'simple',
|
|
226
|
+
path: 'storage/logs',
|
|
227
|
+
prefix: '${projectName}',
|
|
228
|
+
level: 'warn',
|
|
229
|
+
maxFiles: 30,
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
};
|
|
201
233
|
`,
|
|
202
234
|
|
|
203
235
|
// ─── config/app.js ────────────────────────────────────────────
|
|
@@ -358,37 +390,17 @@ millas make:controller UserController
|
|
|
358
390
|
|
|
359
391
|
# Generate a model
|
|
360
392
|
millas make:model User
|
|
361
|
-
\`\`\`
|
|
362
393
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
Millas handles migrations automatically — you only edit your model files.
|
|
366
|
-
|
|
367
|
-
\`\`\`bash
|
|
368
|
-
# 1. Edit app/models/User.js — add, remove, or change fields
|
|
369
|
-
# 2. Generate migration files from your changes
|
|
370
|
-
millas makemigrations
|
|
371
|
-
|
|
372
|
-
# 3. Apply pending migrations to the database
|
|
394
|
+
# Run migrations
|
|
373
395
|
millas migrate
|
|
374
396
|
\`\`\`
|
|
375
397
|
|
|
376
|
-
Other migration commands:
|
|
377
|
-
|
|
378
|
-
\`\`\`bash
|
|
379
|
-
millas migrate:status # Show which migrations have run
|
|
380
|
-
millas migrate:rollback # Undo the last batch
|
|
381
|
-
millas migrate:fresh # Drop everything and re-run all migrations
|
|
382
|
-
millas migrate:reset # Roll back all migrations
|
|
383
|
-
millas migrate:refresh # Reset + re-run (like fresh but using down() methods)
|
|
384
|
-
\`\`\`
|
|
385
|
-
|
|
386
398
|
## Project Structure
|
|
387
399
|
|
|
388
400
|
\`\`\`
|
|
389
401
|
app/
|
|
390
402
|
controllers/ # HTTP controllers
|
|
391
|
-
models/ # ORM models
|
|
403
|
+
models/ # ORM models
|
|
392
404
|
services/ # Business logic
|
|
393
405
|
middleware/ # HTTP middleware
|
|
394
406
|
jobs/ # Background jobs
|
|
@@ -396,14 +408,13 @@ bootstrap/
|
|
|
396
408
|
app.js # Application entry point
|
|
397
409
|
config/ # Configuration files
|
|
398
410
|
database/
|
|
399
|
-
migrations/ #
|
|
411
|
+
migrations/ # Database migrations
|
|
400
412
|
seeders/ # Database seeders
|
|
401
413
|
routes/
|
|
402
414
|
web.js # Web routes
|
|
403
415
|
api.js # API routes
|
|
404
416
|
storage/ # Logs, uploads
|
|
405
417
|
providers/ # Service providers
|
|
406
|
-
.millas/ # Migration snapshot (gitignored)
|
|
407
418
|
\`\`\`
|
|
408
419
|
`,
|
|
409
420
|
};
|