mohen 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +40 -0
- package/.github/workflows/publish.yml +38 -0
- package/README.md +198 -0
- package/dist/logger.d.ts +72 -0
- package/dist/logger.js +389 -0
- package/example/usage.ts +161 -0
- package/logo.png +0 -0
- package/package.json +40 -0
- package/src/logger.ts +454 -0
- package/test/logger.test.ts +499 -0
- package/test/test-server.ts +52 -0
- package/tsconfig.json +29 -0
- package/vitest.config.ts +13 -0
package/src/logger.ts
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Types
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
interface LogEntry {
|
|
10
|
+
timestamp: string;
|
|
11
|
+
requestId: string;
|
|
12
|
+
type: 'http' | 'trpc';
|
|
13
|
+
method: string;
|
|
14
|
+
path: string;
|
|
15
|
+
statusCode?: number;
|
|
16
|
+
duration: number;
|
|
17
|
+
request?: {
|
|
18
|
+
body?: unknown;
|
|
19
|
+
query?: unknown;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
response?: {
|
|
23
|
+
body?: unknown;
|
|
24
|
+
streaming?: boolean;
|
|
25
|
+
chunks?: unknown[];
|
|
26
|
+
};
|
|
27
|
+
error?: {
|
|
28
|
+
message: string;
|
|
29
|
+
stack?: string;
|
|
30
|
+
};
|
|
31
|
+
metadata?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface LoggerOptions {
|
|
35
|
+
maxSizeBytes?: number; // Default: 10MB
|
|
36
|
+
includeHeaders?: boolean; // Default: false
|
|
37
|
+
redact?: string[]; // Fields to redact from logs
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Extend Express Request to include metadata
|
|
41
|
+
declare global {
|
|
42
|
+
namespace Express {
|
|
43
|
+
interface Request {
|
|
44
|
+
logMetadata?: Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// Core Logger Class
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
class UnifiedLogger {
|
|
54
|
+
private filePath: string;
|
|
55
|
+
private maxSizeBytes: number;
|
|
56
|
+
private includeHeaders: boolean;
|
|
57
|
+
private redactFields: Set<string>;
|
|
58
|
+
|
|
59
|
+
constructor(filePath: string, options: LoggerOptions = {}) {
|
|
60
|
+
this.filePath = path.resolve(filePath);
|
|
61
|
+
this.maxSizeBytes = options.maxSizeBytes ?? 10 * 1024 * 1024; // 10MB default
|
|
62
|
+
this.includeHeaders = options.includeHeaders ?? false;
|
|
63
|
+
this.redactFields = new Set(options.redact ?? ['password', 'token', 'authorization', 'cookie']);
|
|
64
|
+
|
|
65
|
+
// Ensure directory exists
|
|
66
|
+
const dir = path.dirname(this.filePath);
|
|
67
|
+
if (!fs.existsSync(dir)) {
|
|
68
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private generateRequestId(): string {
|
|
73
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private redact(obj: unknown): unknown {
|
|
77
|
+
if (obj === null || obj === undefined) return obj;
|
|
78
|
+
if (typeof obj !== 'object') return obj;
|
|
79
|
+
|
|
80
|
+
if (Array.isArray(obj)) {
|
|
81
|
+
return obj.map((item) => this.redact(item));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const result: Record<string, unknown> = {};
|
|
85
|
+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
86
|
+
if (this.redactFields.has(key.toLowerCase())) {
|
|
87
|
+
result[key] = '[REDACTED]';
|
|
88
|
+
} else if (typeof value === 'object') {
|
|
89
|
+
result[key] = this.redact(value);
|
|
90
|
+
} else {
|
|
91
|
+
result[key] = value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private checkAndRotate(): void {
|
|
98
|
+
try {
|
|
99
|
+
if (!fs.existsSync(this.filePath)) return;
|
|
100
|
+
|
|
101
|
+
const stats = fs.statSync(this.filePath);
|
|
102
|
+
if (stats.size > this.maxSizeBytes) {
|
|
103
|
+
// Read file, keep last 25% of lines
|
|
104
|
+
const content = fs.readFileSync(this.filePath, 'utf-8');
|
|
105
|
+
const lines = content.trim().split('\n');
|
|
106
|
+
const keepCount = Math.floor(lines.length * 0.25);
|
|
107
|
+
const newContent = lines.slice(-keepCount).join('\n') + '\n';
|
|
108
|
+
fs.writeFileSync(this.filePath, newContent);
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error('Logger rotation error:', err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
write(entry: LogEntry): void {
|
|
116
|
+
try {
|
|
117
|
+
this.checkAndRotate();
|
|
118
|
+
const redactedEntry = this.redact(entry) as LogEntry;
|
|
119
|
+
const line = JSON.stringify(redactedEntry) + '\n';
|
|
120
|
+
fs.appendFileSync(this.filePath, line);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.error('Logger write error:', err);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ===========================================================================
|
|
127
|
+
// Express Middleware
|
|
128
|
+
// ===========================================================================
|
|
129
|
+
|
|
130
|
+
expressMiddleware() {
|
|
131
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
132
|
+
const start = Date.now();
|
|
133
|
+
const requestId = this.generateRequestId();
|
|
134
|
+
|
|
135
|
+
// Initialize metadata object on request
|
|
136
|
+
req.logMetadata = {};
|
|
137
|
+
|
|
138
|
+
// Detect SSE - check both request Accept header and response Content-Type
|
|
139
|
+
let isSSE = req.headers.accept === 'text/event-stream';
|
|
140
|
+
const chunks: unknown[] = [];
|
|
141
|
+
|
|
142
|
+
// Intercept setHeader to detect SSE by Content-Type
|
|
143
|
+
const originalSetHeader = res.setHeader.bind(res);
|
|
144
|
+
res.setHeader = ((name: string, value: string | number | readonly string[]): Response => {
|
|
145
|
+
if (name.toLowerCase() === 'content-type' &&
|
|
146
|
+
typeof value === 'string' &&
|
|
147
|
+
value.includes('text/event-stream')) {
|
|
148
|
+
isSSE = true;
|
|
149
|
+
}
|
|
150
|
+
return originalSetHeader(name, value);
|
|
151
|
+
}) as typeof res.setHeader;
|
|
152
|
+
|
|
153
|
+
// Capture request info
|
|
154
|
+
const requestInfo: LogEntry['request'] = {
|
|
155
|
+
body: req.body,
|
|
156
|
+
query: req.query,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (this.includeHeaders) {
|
|
160
|
+
requestInfo.headers = req.headers as Record<string, string>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Intercept write/end for streaming detection
|
|
164
|
+
const originalWrite = res.write.bind(res);
|
|
165
|
+
const originalEnd = res.end.bind(res);
|
|
166
|
+
const originalJson = res.json.bind(res);
|
|
167
|
+
const originalSend = res.send.bind(res);
|
|
168
|
+
let responseBody: unknown;
|
|
169
|
+
let logged = false;
|
|
170
|
+
|
|
171
|
+
res.write = ((chunk: any, encodingOrCallback?: any, callback?: any): boolean => {
|
|
172
|
+
// If write is called, treat as streaming
|
|
173
|
+
if (chunk && isSSE) {
|
|
174
|
+
const chunkStr = chunk.toString();
|
|
175
|
+
const parsed = this.parseSSEChunk(chunkStr);
|
|
176
|
+
if (parsed) {
|
|
177
|
+
chunks.push(parsed);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return originalWrite(chunk, encodingOrCallback, callback);
|
|
181
|
+
}) as typeof res.write;
|
|
182
|
+
|
|
183
|
+
res.end = ((chunk?: any, encodingOrCallback?: any, callback?: any): Response => {
|
|
184
|
+
if (logged) return originalEnd(chunk, encodingOrCallback, callback);
|
|
185
|
+
logged = true;
|
|
186
|
+
|
|
187
|
+
if (isSSE) {
|
|
188
|
+
// SSE streaming path
|
|
189
|
+
if (chunk) {
|
|
190
|
+
const chunkStr = chunk.toString();
|
|
191
|
+
const parsed = this.parseSSEChunk(chunkStr);
|
|
192
|
+
if (parsed) {
|
|
193
|
+
chunks.push(parsed);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const entry: LogEntry = {
|
|
198
|
+
timestamp: new Date().toISOString(),
|
|
199
|
+
requestId,
|
|
200
|
+
type: 'http',
|
|
201
|
+
method: req.method,
|
|
202
|
+
path: req.originalUrl || req.url,
|
|
203
|
+
statusCode: res.statusCode,
|
|
204
|
+
duration: Date.now() - start,
|
|
205
|
+
request: requestInfo,
|
|
206
|
+
response: {
|
|
207
|
+
streaming: true,
|
|
208
|
+
chunks,
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
if (req.logMetadata && Object.keys(req.logMetadata).length > 0) {
|
|
213
|
+
entry.metadata = req.logMetadata;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.write(entry);
|
|
217
|
+
} else {
|
|
218
|
+
// Regular response path
|
|
219
|
+
const entry: LogEntry = {
|
|
220
|
+
timestamp: new Date().toISOString(),
|
|
221
|
+
requestId,
|
|
222
|
+
type: 'http',
|
|
223
|
+
method: req.method,
|
|
224
|
+
path: req.originalUrl || req.url,
|
|
225
|
+
statusCode: res.statusCode,
|
|
226
|
+
duration: Date.now() - start,
|
|
227
|
+
request: requestInfo,
|
|
228
|
+
response: {
|
|
229
|
+
body: responseBody,
|
|
230
|
+
streaming: false,
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
if (req.logMetadata && Object.keys(req.logMetadata).length > 0) {
|
|
235
|
+
entry.metadata = req.logMetadata;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this.write(entry);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return originalEnd(chunk, encodingOrCallback, callback);
|
|
242
|
+
}) as typeof res.end;
|
|
243
|
+
|
|
244
|
+
const logResponse = () => {
|
|
245
|
+
if (logged) return;
|
|
246
|
+
logged = true;
|
|
247
|
+
|
|
248
|
+
const entry: LogEntry = {
|
|
249
|
+
timestamp: new Date().toISOString(),
|
|
250
|
+
requestId,
|
|
251
|
+
type: 'http',
|
|
252
|
+
method: req.method,
|
|
253
|
+
path: req.originalUrl || req.url,
|
|
254
|
+
statusCode: res.statusCode,
|
|
255
|
+
duration: Date.now() - start,
|
|
256
|
+
request: requestInfo,
|
|
257
|
+
response: {
|
|
258
|
+
body: responseBody,
|
|
259
|
+
streaming: false,
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
if (req.logMetadata && Object.keys(req.logMetadata).length > 0) {
|
|
264
|
+
entry.metadata = req.logMetadata;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
this.write(entry);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
res.json = (body: any) => {
|
|
271
|
+
responseBody = body;
|
|
272
|
+
logResponse();
|
|
273
|
+
return originalJson(body);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
res.send = (body: any) => {
|
|
277
|
+
if (!logged) {
|
|
278
|
+
try {
|
|
279
|
+
responseBody = typeof body === 'string' ? JSON.parse(body) : body;
|
|
280
|
+
} catch {
|
|
281
|
+
responseBody = body;
|
|
282
|
+
}
|
|
283
|
+
logResponse();
|
|
284
|
+
}
|
|
285
|
+
return originalSend(body);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
next();
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private parseSSEChunk(raw: string): unknown {
|
|
293
|
+
const lines = raw.split('\n');
|
|
294
|
+
for (const line of lines) {
|
|
295
|
+
if (line.startsWith('data: ')) {
|
|
296
|
+
const data = line.slice(6).trim();
|
|
297
|
+
if (data === '[DONE]') return { done: true };
|
|
298
|
+
try {
|
|
299
|
+
return JSON.parse(data);
|
|
300
|
+
} catch {
|
|
301
|
+
return { raw: data };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ===========================================================================
|
|
309
|
+
// tRPC Middleware
|
|
310
|
+
// ===========================================================================
|
|
311
|
+
|
|
312
|
+
trpcMiddleware<TContext extends Record<string, unknown> = Record<string, unknown>>() {
|
|
313
|
+
const logger = this;
|
|
314
|
+
|
|
315
|
+
return async function loggerMiddleware(opts: {
|
|
316
|
+
path: string;
|
|
317
|
+
type: 'query' | 'mutation' | 'subscription';
|
|
318
|
+
input: unknown;
|
|
319
|
+
ctx: TContext & { logMetadata?: Record<string, unknown> };
|
|
320
|
+
next: () => Promise<{ ok: boolean; data?: unknown; error?: Error }>;
|
|
321
|
+
}) {
|
|
322
|
+
const start = Date.now();
|
|
323
|
+
const requestId = logger.generateRequestId();
|
|
324
|
+
|
|
325
|
+
// Initialize metadata on context if not present
|
|
326
|
+
if (!opts.ctx.logMetadata) {
|
|
327
|
+
opts.ctx.logMetadata = {};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const result = await opts.next();
|
|
332
|
+
|
|
333
|
+
const entry: LogEntry = {
|
|
334
|
+
timestamp: new Date().toISOString(),
|
|
335
|
+
requestId,
|
|
336
|
+
type: 'trpc',
|
|
337
|
+
method: opts.type.toUpperCase(),
|
|
338
|
+
path: opts.path,
|
|
339
|
+
statusCode: result.ok ? 200 : 500,
|
|
340
|
+
duration: Date.now() - start,
|
|
341
|
+
request: {
|
|
342
|
+
body: opts.input,
|
|
343
|
+
},
|
|
344
|
+
response: {
|
|
345
|
+
body: result.data,
|
|
346
|
+
streaming: false,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Attach metadata if present
|
|
351
|
+
if (opts.ctx.logMetadata && Object.keys(opts.ctx.logMetadata).length > 0) {
|
|
352
|
+
entry.metadata = opts.ctx.logMetadata;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (result.error) {
|
|
356
|
+
entry.error = {
|
|
357
|
+
message: result.error.message,
|
|
358
|
+
stack: result.error.stack,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
logger.write(entry);
|
|
363
|
+
|
|
364
|
+
return result;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
const err = error as Error;
|
|
367
|
+
|
|
368
|
+
const entry: LogEntry = {
|
|
369
|
+
timestamp: new Date().toISOString(),
|
|
370
|
+
requestId,
|
|
371
|
+
type: 'trpc',
|
|
372
|
+
method: opts.type.toUpperCase(),
|
|
373
|
+
path: opts.path,
|
|
374
|
+
statusCode: 500,
|
|
375
|
+
duration: Date.now() - start,
|
|
376
|
+
request: {
|
|
377
|
+
body: opts.input,
|
|
378
|
+
},
|
|
379
|
+
error: {
|
|
380
|
+
message: err.message,
|
|
381
|
+
stack: err.stack,
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// Attach metadata if present
|
|
386
|
+
if (opts.ctx.logMetadata && Object.keys(opts.ctx.logMetadata).length > 0) {
|
|
387
|
+
entry.metadata = opts.ctx.logMetadata;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
logger.write(entry);
|
|
391
|
+
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ============================================================================
|
|
399
|
+
// Factory Function (Main Export)
|
|
400
|
+
// ============================================================================
|
|
401
|
+
|
|
402
|
+
export function createLogger(filePath: string, options?: LoggerOptions) {
|
|
403
|
+
const logger = new UnifiedLogger(filePath, options);
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
/** Express middleware - use with app.use() */
|
|
407
|
+
express: () => logger.expressMiddleware(),
|
|
408
|
+
|
|
409
|
+
/** tRPC middleware - use with t.procedure.use() */
|
|
410
|
+
trpc: <TContext extends Record<string, unknown> = Record<string, unknown>>() =>
|
|
411
|
+
logger.trpcMiddleware<TContext>(),
|
|
412
|
+
|
|
413
|
+
/** Direct write access for custom logging */
|
|
414
|
+
write: (entry: Partial<LogEntry>) => logger.write({
|
|
415
|
+
timestamp: new Date().toISOString(),
|
|
416
|
+
requestId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`,
|
|
417
|
+
type: 'http',
|
|
418
|
+
method: 'CUSTOM',
|
|
419
|
+
path: '/',
|
|
420
|
+
duration: 0,
|
|
421
|
+
...entry,
|
|
422
|
+
} as LogEntry),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ============================================================================
|
|
427
|
+
// Helper to attach metadata (for cleaner API)
|
|
428
|
+
// ============================================================================
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Attach metadata to the current request log entry (Express)
|
|
432
|
+
*/
|
|
433
|
+
export function attachMetadata(req: Request, metadata: Record<string, unknown>): void {
|
|
434
|
+
if (!req.logMetadata) {
|
|
435
|
+
req.logMetadata = {};
|
|
436
|
+
}
|
|
437
|
+
Object.assign(req.logMetadata, metadata);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Attach metadata to the current request log entry (tRPC)
|
|
442
|
+
*/
|
|
443
|
+
export function attachTrpcMetadata<TContext extends { logMetadata?: Record<string, unknown> }>(
|
|
444
|
+
ctx: TContext,
|
|
445
|
+
metadata: Record<string, unknown>
|
|
446
|
+
): void {
|
|
447
|
+
if (!ctx.logMetadata) {
|
|
448
|
+
ctx.logMetadata = {};
|
|
449
|
+
}
|
|
450
|
+
Object.assign(ctx.logMetadata, metadata);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Default export for simpler imports
|
|
454
|
+
export default createLogger;
|