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
All checks were successful
Code quality checks / biome (push) Successful in 9s
This commit is contained in:
parent
108083e0f5
commit
2c4f3cf5de
6 changed files with 187 additions and 130 deletions
53
README.md
53
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
|
||||
|
|
|
@ -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",
|
||||
|
|
47
src/index.ts
47
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<LoggerConfig>;
|
||||
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<LoggerConfig> {
|
||||
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<void> {
|
||||
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";
|
||||
|
|
|
@ -41,13 +41,14 @@ function getCallerInfo(config: Required<LoggerConfig>): {
|
|||
} {
|
||||
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<LoggerConfig>): {
|
|||
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<LoggerConfig>): {
|
|||
fileName: basename(fullPath),
|
||||
line: lineNumber,
|
||||
column: columnNumber,
|
||||
timestamp: getTimestamp(config).timestamp,
|
||||
prettyTimestamp: getTimestamp(config).prettyTimestamp,
|
||||
timestamp: timestampInfo.timestamp,
|
||||
prettyTimestamp: timestampInfo.prettyTimestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
195
src/lib/file.ts
195
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<LoggerConfig>,
|
||||
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<LoggerConfig>,
|
||||
): void {
|
||||
if (config.disableFile) return;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (!existsSync(config.directory)) {
|
||||
mkdirSync(config.directory, { recursive: true });
|
||||
constructor(private readonly config: Required<LoggerConfig>) {
|
||||
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<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", () => {
|
||||
currentStream?.end();
|
||||
});
|
||||
process.on("SIGINT", () => {
|
||||
currentStream?.end();
|
||||
process.exit();
|
||||
});
|
||||
export { FileLogger };
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue