From 2c4f3cf5debb41fc4c1db3ebe7f0427ac25625be Mon Sep 17 00:00:00 2001 From: creations Date: Sun, 25 May 2025 08:27:20 -0400 Subject: [PATCH] fix the max files count and deletion missing ?!?!, fix readme to actually have all the values for the json example, refactor file to class, fix custom not logging to file?, allow public flush --- README.md | 53 +++++++++---- package.json | 5 +- src/index.ts | 47 +++++++----- src/lib/char.ts | 15 ++-- src/lib/file.ts | 195 +++++++++++++++++++++++++++--------------------- tsconfig.json | 2 +- 6 files changed, 187 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index a6e2029..8714f5a 100644 --- a/README.md +++ b/README.md @@ -69,20 +69,43 @@ constructor > environment > logger.json > defaults ```json { - "directory": "logs", - "level": "debug", - "console": true, - "consoleColor": true, - "rotate": true, - "maxFiles": 3, - "prettyPrint": true, - "pattern": "{color:gray}{timestamp}{reset} {color:levelColor}[{level-name}]{reset} ({file-name}:{line}:{column}) {data}", - "customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}", - "customColors": { - "GET": "green", - "POST": "blue", - "DELETE": "red" - } + "directory": "logs", + "level": "debug", + "disableFile": false, + + "rotate": true, + "maxFiles": 3, + + "console": true, + "consoleColor": true, + + "dateFormat": "yyyy-MM-dd HH:mm:ss.SSS", + "timezone": "local", + + "silent": false, + + "pattern": "{color:gray}{timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{line}:{column}{color:gray}){reset} {data}", + "levelColor": { + "debug": "blue", + "info": "green", + "warn": "yellow", + "error": "red", + "fatal": "red" + }, + + "customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}", + "customColors": { + "GET": "red", + "POST": "blue", + "PUT": "yellow", + "DELETE": "red", + "PATCH": "cyan", + "HEAD": "magenta", + "OPTIONS": "white", + "TRACE": "gray" + }, + + "prettyPrint": true } ``` @@ -158,7 +181,7 @@ The output format is controlled by: ## Output Examples -### Console (with colors) +### Console ``` 2025-05-24 16:15:00.000 [INFO] (index.ts:3:6) Server started diff --git a/package.json b/package.json index 6b6e245..0238cd0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@atums/echo", - "version": "1.0.1", - "description": "", + "version": "1.0.2", + "description": "A minimal, flexible logger", "private": false, "type": "module", "main": "./dist/index.js", @@ -28,6 +28,7 @@ } }, "scripts": { + "dev": "bun run build --watch", "build": "rm -rf dist && tsup src/index.ts --dts --out-dir dist --format esm,cjs", "lint": "bunx biome check", "lint:fix": "bunx biome check --fix", diff --git a/src/index.ts b/src/index.ts index f1fe34f..a3b0719 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,12 +16,13 @@ import { loadLoggerConfig, logLevelValues, } from "@lib/config"; -import { writeLogJson } from "@lib/file"; +import { FileLogger } from "@lib/file"; import type { LogLevel, LoggerConfig } from "@types"; class Echo { private readonly directory: string; private readonly config: Required; + private readonly fileLogger: FileLogger | null = null; constructor(config?: string | LoggerConfig) { const fileConfig: LoggerConfig = @@ -45,6 +46,8 @@ class Echo { if (!this.config.disableFile) { Echo.validateDirectory(this.directory); + + this.fileLogger = new FileLogger(this.config); } } @@ -61,14 +64,6 @@ class Echo { accessSync(dir, constants.W_OK); } - public getDirectory(): string { - return this.directory; - } - - public getConfig(): Required { - return this.config; - } - private log(level: LogLevel, data: unknown): void { if ( this.config.silent || @@ -85,8 +80,8 @@ class Echo { ); } - if (!this.config.disableFile) { - writeLogJson(level, data, meta, this.config); + if (!this.config.disableFile && this.fileLogger) { + this.fileLogger.write(level, data, meta); } } @@ -114,8 +109,8 @@ class Echo { this.log("trace", data); } - public custom(tag: string, context: string, message: unknown): void { - if (this.config.silent || !this.config.console) return; + public custom(tag: string, context: string, data: unknown): void { + if (this.config.silent) return; const timestamps = getTimestamp(this.config); @@ -128,14 +123,14 @@ class Echo { const reset = this.config.consoleColor ? ansiColors.reset : ""; const resolvedData = - this.config.prettyPrint && typeof message === "object" && message !== null - ? inspect(message, { + this.config.prettyPrint && typeof data === "object" && data !== null + ? inspect(data, { depth: null, colors: this.config.consoleColor, breakLength: 1, compact: false, }) - : format(message); + : format(data); const pattern = this.config.customPattern ?? @@ -152,9 +147,25 @@ class Echo { .replace(/{color:contextColor}/g, contextColor) .replace(/{reset}/g, reset); - console.log(line); + if (this.config.console) { + console.log(line); + } + + if (!this.config.disableFile && this.fileLogger) { + const meta = getCallerInfo(this.config); + this.fileLogger.write(tag, { context, data }, meta); + } + } + + public flush(): Promise { + return this.fileLogger?.flush() ?? Promise.resolve(); } } +function createLogger(config?: string | LoggerConfig): Echo { + return new Echo(config); +} + const echo = new Echo(); -export { echo, Echo }; +export { echo, Echo, createLogger }; +export type { LoggerConfig, LogLevel } from "@types"; diff --git a/src/lib/char.ts b/src/lib/char.ts index 7625656..ae29015 100644 --- a/src/lib/char.ts +++ b/src/lib/char.ts @@ -41,13 +41,14 @@ function getCallerInfo(config: Required): { } { const id = generateShortId(); + const timestampInfo = getTimestamp(config); const fallback = { - id: id, + id, fileName: "unknown", line: "0", - timestamp: getTimestamp(config).timestamp, - prettyTimestamp: getTimestamp(config).prettyTimestamp, column: "0", + timestamp: timestampInfo.timestamp, + prettyTimestamp: timestampInfo.prettyTimestamp, }; const stack = new Error().stack; @@ -73,8 +74,8 @@ function getCallerInfo(config: Required): { fileName: basename(fullPath), line: lineNumber, column: columnNumber, - timestamp: getTimestamp(config).timestamp, - prettyTimestamp: getTimestamp(config).prettyTimestamp, + timestamp: timestampInfo.timestamp, + prettyTimestamp: timestampInfo.prettyTimestamp, }; } @@ -97,8 +98,8 @@ function getCallerInfo(config: Required): { fileName: basename(fullPath), line: lineNumber, column: columnNumber, - timestamp: getTimestamp(config).timestamp, - prettyTimestamp: getTimestamp(config).prettyTimestamp, + timestamp: timestampInfo.timestamp, + prettyTimestamp: timestampInfo.prettyTimestamp, }; } } diff --git a/src/lib/file.ts b/src/lib/file.ts index 3eb8414..1a7e372 100644 --- a/src/lib/file.ts +++ b/src/lib/file.ts @@ -3,103 +3,124 @@ import { createWriteStream, existsSync, mkdirSync, + readdirSync, + unlinkSync, } from "node:fs"; import { join } from "node:path"; import type { LogLevel, LoggerConfig } from "@types"; import { format } from "date-fns-tz"; -let currentStream: WriteStream | null = null; -let currentFilePath = ""; -let currentDate = ""; +class FileLogger { + private stream: WriteStream | null = null; + private filePath = ""; + private date = ""; -function getLogFilePath( - config: Required, - dateStr: string, -): string { - return join(config.directory, `${dateStr}.jsonl`); -} - -function resetStream(path: string): void { - currentStream?.end(); - currentStream = createWriteStream(path, { flags: "a", encoding: "utf-8" }); - currentFilePath = path; -} - -export function writeLogJson( - level: LogLevel, - data: unknown, - meta: { - id: string; - fileName: string; - line: string; - column: string; - timestamp: string; - prettyTimestamp: string; - }, - config: Required, -): void { - if (config.disableFile) return; - - const now = new Date(); - - if (!existsSync(config.directory)) { - mkdirSync(config.directory, { recursive: true }); + constructor(private readonly config: Required) { + if (!existsSync(this.config.directory)) { + mkdirSync(this.config.directory, { recursive: true }); + } } - let filePath: string; + private getLogFilePath(dateStr: string): string { + return join(this.config.directory, `${dateStr}.jsonl`); + } - if (config.rotate) { - const dateStr = format(now, "yyyy-MM-dd", { - timeZone: config.timezone, + private resetStream(path: string): void { + this.stream?.end(); + this.stream = createWriteStream(path, { flags: "a", encoding: "utf-8" }); + this.filePath = path; + } + + private pruneOldLogs(): void { + if (this.config.maxFiles && this.config.maxFiles < 1) { + throw new Error("[@atums/echo] maxFiles must be >= 1 if set."); + } + + const files = readdirSync(this.config.directory) + .filter((file) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(file)) + .sort(); + + const excess = files.slice( + 0, + Math.max(0, files.length - this.config.maxFiles), + ); + + for (const file of excess) { + try { + unlinkSync(join(this.config.directory, file)); + } catch {} + } + } + + public write( + level: LogLevel | string, + data: unknown, + meta: { + id: string; + fileName: string; + line: string; + column: string; + timestamp: string; + prettyTimestamp: string; + }, + ): void { + if (this.config.disableFile) return; + + const now = new Date(); + let path: string; + + if (this.config.rotate) { + const dateStr = format(now, "yyyy-MM-dd", { + timeZone: this.config.timezone, + }); + path = this.getLogFilePath(dateStr); + + if (!this.stream || this.filePath !== path || this.date !== dateStr) { + this.date = dateStr; + this.resetStream(path); + this.pruneOldLogs(); + } + } else { + path = join(this.config.directory, "log.jsonl"); + + if (!this.stream || this.filePath !== path) { + this.resetStream(path); + } + } + + const line = `${JSON.stringify({ + timestamp: new Date(meta.timestamp).getTime(), + level, + id: meta.id, + file: meta.fileName, + line: meta.line, + column: meta.column, + data: + data instanceof Error + ? { + name: data.name, + message: data.message, + stack: data.stack, + } + : typeof data === "string" || typeof data === "number" + ? data + : data, + })}\n`; + + try { + this.stream?.write(line); + } catch (err) { + if (this.config.console) { + throw new Error(`[@atums/echo] Failed to write to log file: ${err}`); + } + } + } + + public flush(): Promise { + return new Promise((resolve) => { + this.stream?.end(resolve); }); - filePath = getLogFilePath(config, dateStr); - - if ( - currentStream === null || - currentFilePath !== filePath || - currentDate !== dateStr - ) { - currentDate = dateStr; - resetStream(filePath); - } - } else { - filePath = join(config.directory, "log.jsonl"); - - if (currentStream === null || currentFilePath !== filePath) { - resetStream(filePath); - } } - - const line = `${JSON.stringify({ - timestamp: new Date(meta.timestamp).getTime(), - level, - id: meta.id, - file: meta.fileName, - line: meta.line, - column: meta.column, - data: - data instanceof Error - ? { - name: data.name, - message: data.message, - stack: data.stack, - } - : typeof data === "string" || typeof data === "number" - ? data - : data, - })}\n`; - - if (currentStream === null) { - throw new Error("Logger stream is not initialized"); - } - - currentStream.write(line); } -process.on("exit", () => { - currentStream?.end(); -}); -process.on("SIGINT", () => { - currentStream?.end(); - process.exit(); -}); +export { FileLogger }; diff --git a/tsconfig.json b/tsconfig.json index 231a4a8..31ec442 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "moduleDetection": "force", "allowJs": true, "moduleResolution": "bundler", - "allowImportingTsExtensions": true, + "allowImportingTsExtensions": false, "verbatimModuleSyntax": true, "noEmit": false, "declaration": true,