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
All checks were successful
Code quality checks / biome (push) Successful in 9s

This commit is contained in:
creations 2025-05-25 08:27:20 -04:00
parent 108083e0f5
commit 2c4f3cf5de
Signed by: creations
GPG key ID: 8F553AA4320FC711
6 changed files with 187 additions and 130 deletions

View file

@ -69,20 +69,43 @@ constructor > environment > logger.json > defaults
```json ```json
{ {
"directory": "logs", "directory": "logs",
"level": "debug", "level": "debug",
"console": true, "disableFile": false,
"consoleColor": true,
"rotate": true, "rotate": true,
"maxFiles": 3, "maxFiles": 3,
"prettyPrint": true,
"pattern": "{color:gray}{timestamp}{reset} {color:levelColor}[{level-name}]{reset} ({file-name}:{line}:{column}) {data}", "console": true,
"customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}", "consoleColor": true,
"customColors": {
"GET": "green", "dateFormat": "yyyy-MM-dd HH:mm:ss.SSS",
"POST": "blue", "timezone": "local",
"DELETE": "red"
} "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 ## Output Examples
### Console (with colors) ### Console
``` ```
2025-05-24 16:15:00.000 [INFO] (index.ts:3:6) Server started 2025-05-24 16:15:00.000 [INFO] (index.ts:3:6) Server started

View file

@ -1,7 +1,7 @@
{ {
"name": "@atums/echo", "name": "@atums/echo",
"version": "1.0.1", "version": "1.0.2",
"description": "", "description": "A minimal, flexible logger",
"private": false, "private": false,
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@ -28,6 +28,7 @@
} }
}, },
"scripts": { "scripts": {
"dev": "bun run build --watch",
"build": "rm -rf dist && tsup src/index.ts --dts --out-dir dist --format esm,cjs", "build": "rm -rf dist && tsup src/index.ts --dts --out-dir dist --format esm,cjs",
"lint": "bunx biome check", "lint": "bunx biome check",
"lint:fix": "bunx biome check --fix", "lint:fix": "bunx biome check --fix",

View file

@ -16,12 +16,13 @@ import {
loadLoggerConfig, loadLoggerConfig,
logLevelValues, logLevelValues,
} from "@lib/config"; } from "@lib/config";
import { writeLogJson } from "@lib/file"; import { FileLogger } from "@lib/file";
import type { LogLevel, LoggerConfig } from "@types"; import type { LogLevel, LoggerConfig } from "@types";
class Echo { class Echo {
private readonly directory: string; private readonly directory: string;
private readonly config: Required<LoggerConfig>; private readonly config: Required<LoggerConfig>;
private readonly fileLogger: FileLogger | null = null;
constructor(config?: string | LoggerConfig) { constructor(config?: string | LoggerConfig) {
const fileConfig: LoggerConfig = const fileConfig: LoggerConfig =
@ -45,6 +46,8 @@ class Echo {
if (!this.config.disableFile) { if (!this.config.disableFile) {
Echo.validateDirectory(this.directory); Echo.validateDirectory(this.directory);
this.fileLogger = new FileLogger(this.config);
} }
} }
@ -61,14 +64,6 @@ class Echo {
accessSync(dir, constants.W_OK); accessSync(dir, constants.W_OK);
} }
public getDirectory(): string {
return this.directory;
}
public getConfig(): Required<LoggerConfig> {
return this.config;
}
private log(level: LogLevel, data: unknown): void { private log(level: LogLevel, data: unknown): void {
if ( if (
this.config.silent || this.config.silent ||
@ -85,8 +80,8 @@ class Echo {
); );
} }
if (!this.config.disableFile) { if (!this.config.disableFile && this.fileLogger) {
writeLogJson(level, data, meta, this.config); this.fileLogger.write(level, data, meta);
} }
} }
@ -114,8 +109,8 @@ class Echo {
this.log("trace", data); this.log("trace", data);
} }
public custom(tag: string, context: string, message: unknown): void { public custom(tag: string, context: string, data: unknown): void {
if (this.config.silent || !this.config.console) return; if (this.config.silent) return;
const timestamps = getTimestamp(this.config); const timestamps = getTimestamp(this.config);
@ -128,14 +123,14 @@ class Echo {
const reset = this.config.consoleColor ? ansiColors.reset : ""; const reset = this.config.consoleColor ? ansiColors.reset : "";
const resolvedData = const resolvedData =
this.config.prettyPrint && typeof message === "object" && message !== null this.config.prettyPrint && typeof data === "object" && data !== null
? inspect(message, { ? inspect(data, {
depth: null, depth: null,
colors: this.config.consoleColor, colors: this.config.consoleColor,
breakLength: 1, breakLength: 1,
compact: false, compact: false,
}) })
: format(message); : format(data);
const pattern = const pattern =
this.config.customPattern ?? this.config.customPattern ??
@ -152,9 +147,25 @@ class Echo {
.replace(/{color:contextColor}/g, contextColor) .replace(/{color:contextColor}/g, contextColor)
.replace(/{reset}/g, reset); .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<void> {
return this.fileLogger?.flush() ?? Promise.resolve();
} }
} }
function createLogger(config?: string | LoggerConfig): Echo {
return new Echo(config);
}
const echo = new Echo(); const echo = new Echo();
export { echo, Echo }; export { echo, Echo, createLogger };
export type { LoggerConfig, LogLevel } from "@types";

View file

@ -41,13 +41,14 @@ function getCallerInfo(config: Required<LoggerConfig>): {
} { } {
const id = generateShortId(); const id = generateShortId();
const timestampInfo = getTimestamp(config);
const fallback = { const fallback = {
id: id, id,
fileName: "unknown", fileName: "unknown",
line: "0", line: "0",
timestamp: getTimestamp(config).timestamp,
prettyTimestamp: getTimestamp(config).prettyTimestamp,
column: "0", column: "0",
timestamp: timestampInfo.timestamp,
prettyTimestamp: timestampInfo.prettyTimestamp,
}; };
const stack = new Error().stack; const stack = new Error().stack;
@ -73,8 +74,8 @@ function getCallerInfo(config: Required<LoggerConfig>): {
fileName: basename(fullPath), fileName: basename(fullPath),
line: lineNumber, line: lineNumber,
column: columnNumber, column: columnNumber,
timestamp: getTimestamp(config).timestamp, timestamp: timestampInfo.timestamp,
prettyTimestamp: getTimestamp(config).prettyTimestamp, prettyTimestamp: timestampInfo.prettyTimestamp,
}; };
} }
@ -97,8 +98,8 @@ function getCallerInfo(config: Required<LoggerConfig>): {
fileName: basename(fullPath), fileName: basename(fullPath),
line: lineNumber, line: lineNumber,
column: columnNumber, column: columnNumber,
timestamp: getTimestamp(config).timestamp, timestamp: timestampInfo.timestamp,
prettyTimestamp: getTimestamp(config).prettyTimestamp, prettyTimestamp: timestampInfo.prettyTimestamp,
}; };
} }
} }

View file

@ -3,103 +3,124 @@ import {
createWriteStream, createWriteStream,
existsSync, existsSync,
mkdirSync, mkdirSync,
readdirSync,
unlinkSync,
} from "node:fs"; } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import type { LogLevel, LoggerConfig } from "@types"; import type { LogLevel, LoggerConfig } from "@types";
import { format } from "date-fns-tz"; import { format } from "date-fns-tz";
let currentStream: WriteStream | null = null; class FileLogger {
let currentFilePath = ""; private stream: WriteStream | null = null;
let currentDate = ""; private filePath = "";
private date = "";
function getLogFilePath( constructor(private readonly config: Required<LoggerConfig>) {
config: Required<LoggerConfig>, if (!existsSync(this.config.directory)) {
dateStr: string, mkdirSync(this.config.directory, { recursive: true });
): 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<LoggerConfig>,
): void {
if (config.disableFile) return;
const now = new Date();
if (!existsSync(config.directory)) {
mkdirSync(config.directory, { recursive: true });
} }
let filePath: string; private getLogFilePath(dateStr: string): string {
return join(this.config.directory, `${dateStr}.jsonl`);
}
if (config.rotate) { private resetStream(path: string): void {
const dateStr = format(now, "yyyy-MM-dd", { this.stream?.end();
timeZone: config.timezone, 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<void> {
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", () => { export { FileLogger };
currentStream?.end();
});
process.on("SIGINT", () => {
currentStream?.end();
process.exit();
});

View file

@ -14,7 +14,7 @@
"moduleDetection": "force", "moduleDetection": "force",
"allowJs": true, "allowJs": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": false,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"noEmit": false, "noEmit": false,
"declaration": true, "declaration": true,