- added fileNameFormat
All checks were successful
Code quality checks / biome (push) Successful in 11s

- fixed up some logic aka duplicate logic
- actually verify config
-add more ansiColors
-default to no max files
This commit is contained in:
creations 2025-06-11 10:13:27 -04:00
parent 5c88ce3e28
commit f7d2c7084b
Signed by: creations
GPG key ID: 8F553AA4320FC711
8 changed files with 491 additions and 151 deletions

View file

@ -4,7 +4,7 @@ A minimal, flexible logger for Node with:
- Colored console output - Colored console output
- Daily `.jsonl` file logging - Daily `.jsonl` file logging
- Configurable output patterns - Configurable output patterns and file naming
- Structured logs with caller metadata - Structured logs with caller metadata
- Fully typed config with environment/file/constructor override - Fully typed config with environment/file/constructor override
@ -14,7 +14,7 @@ A minimal, flexible logger for Node with:
- Console and file logging with level-based filtering - Console and file logging with level-based filtering
- Colored output with ANSI formatting - Colored output with ANSI formatting
- Daily rotated `.jsonl` files - Daily rotated `.jsonl` files with custom naming patterns
- Supports runtime configuration merging - Supports runtime configuration merging
- Auto-formatted output using custom patterns - Auto-formatted output using custom patterns
- Includes caller file, line, and column - Includes caller file, line, and column
@ -75,6 +75,7 @@ constructor > environment > logger.json > defaults
"rotate": true, "rotate": true,
"maxFiles": 3, "maxFiles": 3,
"fileNameFormat": "yyyy-MM-dd",
"console": true, "console": true,
"consoleColor": true, "consoleColor": true,
@ -121,6 +122,7 @@ constructor > environment > logger.json > defaults
| `LOG_DISABLE_FILE` | Disable file output (`true` or `false`) | | `LOG_DISABLE_FILE` | Disable file output (`true` or `false`) |
| `LOG_ROTATE` | Enable daily rotation | | `LOG_ROTATE` | Enable daily rotation |
| `LOG_MAX_FILES` | Max rotated files to keep | | `LOG_MAX_FILES` | Max rotated files to keep |
| `LOG_FILE_NAME_FORMAT` | Custom file name format (default: yyyy-MM-dd) |
| `LOG_CONSOLE` | Enable console output | | `LOG_CONSOLE` | Enable console output |
| `LOG_CONSOLE_COLOR` | Enable ANSI color in console output | | `LOG_CONSOLE_COLOR` | Enable ANSI color in console output |
| `LOG_DATE_FORMAT` | Date format for display timestamp | | `LOG_DATE_FORMAT` | Date format for display timestamp |
@ -133,6 +135,17 @@ constructor > environment > logger.json > defaults
--- ---
### Custom File Naming
```json
{
"fileNameFormat": "yyyy-MM-dd", // 2025-06-03.jsonl
"fileNameFormat": "yyyy-MM-dd_HH-mm", // 2025-06-03_18-30.jsonl
"fileNameFormat": "yyyyMMdd", // 20250603.jsonl
}
```
---
## Pattern Tokens ## Pattern Tokens
These tokens are replaced in the log pattern: These tokens are replaced in the log pattern:

View file

@ -7,7 +7,7 @@
}, },
"files": { "files": {
"ignoreUnknown": true, "ignoreUnknown": true,
"ignore": ["dist", "types"] "ignore": ["dist"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
@ -29,14 +29,14 @@
"recommended": true, "recommended": true,
"correctness": { "correctness": {
"noUnusedImports": "error", "noUnusedImports": "error",
"useJsxKeyInIterable": "off",
"noUnusedVariables": "error" "noUnusedVariables": "error"
}, },
"style": { "style": {
"useConst": "error", "useConst": "error",
"noVar": "error" "noVar": "error"
} }
} },
"ignore": ["types"]
}, },
"javascript": { "javascript": {
"formatter": { "formatter": {

View file

@ -46,6 +46,12 @@
"type": "git", "type": "git",
"url": "https://git.creations.works/atums/echo" "url": "https://git.creations.works/atums/echo"
}, },
"keywords": ["logger", "logging", "typescript", "nodejs", "structured"],
"author": "creations.works",
"homepage": "https://git.creations.works/atums/echo",
"bugs": {
"url": "https://git.creations.works/atums/echo/issues"
},
"dependencies": { "dependencies": {
"date-fns-tz": "^3.2.0" "date-fns-tz": "^3.2.0"
} }

View file

@ -7,17 +7,24 @@ import {
statSync, statSync,
} from "node:fs"; } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { format, inspect } from "node:util";
import { getCallerInfo, getTimestamp, parsePattern } from "@lib/char";
import { import {
ansiColors, formatData,
getCallerInfo,
getConsoleMethod,
getTimestamp,
parsePattern,
processPattern,
} from "@lib/char";
import {
defaultConfig, defaultConfig,
loadEnvConfig, loadEnvConfig,
loadLoggerConfig, loadLoggerConfig,
logLevelValues, logLevelValues,
validateAndSanitizeConfig,
} from "@lib/config"; } from "@lib/config";
import { FileLogger } from "@lib/file"; import { FileLogger } from "@lib/file";
import type { LogLevel, LoggerConfig } from "@types";
import type { LogLevel, LoggerConfig, PatternTokens } from "@types";
class Echo { class Echo {
private readonly directory: string; private readonly directory: string;
@ -35,12 +42,18 @@ class Echo {
const envConfig: LoggerConfig = loadEnvConfig(); const envConfig: LoggerConfig = loadEnvConfig();
this.config = { const mergedConfig = {
...defaultConfig, ...defaultConfig,
...fileConfig, ...fileConfig,
...envConfig, ...envConfig,
...overrideConfig, ...overrideConfig,
}; };
const finalConfig = validateAndSanitizeConfig(
mergedConfig,
"merged configuration",
);
this.config = finalConfig as Required<LoggerConfig>;
this.directory = resolve(this.config.directory); this.directory = resolve(this.config.directory);
@ -75,9 +88,7 @@ class Echo {
const line = parsePattern({ level, data, config: this.config }); const line = parsePattern({ level, data, config: this.config });
if (this.config.console) { if (this.config.console) {
console[level === "error" ? "error" : level === "warn" ? "warn" : "log"]( console[getConsoleMethod(level)](line);
line,
);
} }
if (!this.config.disableFile && this.fileLogger) { if (!this.config.disableFile && this.fileLogger) {
@ -113,39 +124,21 @@ class Echo {
if (this.config.silent) return; if (this.config.silent) return;
const timestamps = getTimestamp(this.config); const timestamps = getTimestamp(this.config);
const resolvedData = formatData(data, this.config);
const normalizedTag = tag.toUpperCase();
const tagColor = this.config.consoleColor
? (ansiColors[this.config.customColors?.[normalizedTag] ?? "green"] ?? "")
: "";
const contextColor = this.config.consoleColor ? ansiColors.cyan : "";
const gray = this.config.consoleColor ? ansiColors.gray : "";
const reset = this.config.consoleColor ? ansiColors.reset : "";
const resolvedData =
this.config.prettyPrint && typeof data === "object" && data !== null
? inspect(data, {
depth: null,
colors: this.config.consoleColor,
breakLength: 1,
compact: false,
})
: format(data);
const pattern = const pattern =
this.config.customPattern ?? this.config.customPattern ??
"{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}"; "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}";
const line = pattern const tokens: PatternTokens = {
.replace(/{timestamp}/g, timestamps.timestamp) timestamp: timestamps.timestamp,
.replace(/{pretty-timestamp}/g, timestamps.prettyTimestamp) prettyTimestamp: timestamps.prettyTimestamp,
.replace(/{tag}/g, tag) tag,
.replace(/{context}/g, context) context,
.replace(/{data}/g, resolvedData) data: resolvedData,
.replace(/{color:gray}/g, gray) };
.replace(/{color:tagColor}/g, tagColor)
.replace(/{color:contextColor}/g, contextColor) const line = processPattern(pattern, tokens, this.config, undefined, tag);
.replace(/{reset}/g, reset);
if (this.config.console) { if (this.config.console) {
console.log(line); console.log(line);

View file

@ -8,6 +8,7 @@ import type {
LogLevelValue, LogLevelValue,
LoggerConfig, LoggerConfig,
PatternContext, PatternContext,
PatternTokens,
} from "@types"; } from "@types";
function getTimestamp(config: Required<LoggerConfig>): { function getTimestamp(config: Required<LoggerConfig>): {
@ -113,22 +114,121 @@ function generateShortId(length = 8): string {
return id; return id;
} }
function replaceColorTokens( function formatData(data: unknown, config: Required<LoggerConfig>): string {
input: string, return config.prettyPrint && typeof data === "object" && data !== null
level: LogLevel, ? inspect(data, {
depth: null,
colors: config.consoleColor,
breakLength: 1,
compact: false,
})
: format(data);
}
function getConsoleMethod(level: LogLevel): "log" | "warn" | "error" {
if (level === "error" || level === "fatal") return "error";
if (level === "warn") return "warn";
return "log";
}
function resolveColor(
colorKey: string,
config: Required<LoggerConfig>, config: Required<LoggerConfig>,
level?: LogLevel,
tag?: string,
): string { ): string {
return input if (!config.consoleColor) return "";
.replace(/{color:(\w+)}/g, (_, colorKey) => {
if (!config.consoleColor) return ""; if (colorKey === "levelColor" && level) {
if (colorKey === "levelColor") { const colorForLevel =
const colorForLevel = config.levelColor?.[level] ?? defaultLevelColor[level];
config.levelColor?.[level] ?? defaultLevelColor[level]; return ansiColors[colorForLevel ?? ""] ?? "";
return ansiColors[colorForLevel ?? ""] ?? ""; }
}
return ansiColors[colorKey] ?? ""; if (colorKey === "tagColor" && tag) {
}) const normalizedTag = tag.toUpperCase();
.replace(/{reset}/g, config.consoleColor ? ansiColors.reset : ""); return ansiColors[config.customColors?.[normalizedTag] ?? "green"] ?? "";
}
if (colorKey === "contextColor") {
return ansiColors.cyan ?? "";
}
return ansiColors[colorKey] ?? "";
}
function serializeLogData(data: unknown): unknown {
if (data instanceof Error) {
return {
name: data.name,
message: data.message,
stack: data.stack,
};
}
if (typeof data === "string" || typeof data === "number") {
return data;
}
return data;
}
function processPattern(
pattern: string,
tokens: PatternTokens,
config: Required<LoggerConfig>,
level?: LogLevel,
tag?: string,
): string {
let processed = pattern;
if (tokens.timestamp) {
processed = processed.replace(/{timestamp}/g, tokens.timestamp);
}
if (tokens.prettyTimestamp) {
processed = processed.replace(
/{pretty-timestamp}/g,
tokens.prettyTimestamp,
);
}
if (tokens.levelName) {
processed = processed.replace(/{level-name}/g, tokens.levelName);
}
if (tokens.level) {
processed = processed.replace(/{level}/g, tokens.level);
}
if (tokens.fileName) {
processed = processed.replace(/{file-name}/g, tokens.fileName);
}
if (tokens.line) {
processed = processed.replace(/{line}/g, tokens.line);
}
if (tokens.column) {
processed = processed.replace(/{column}/g, tokens.column);
}
if (tokens.data) {
processed = processed.replace(/{data}/g, tokens.data);
}
if (tokens.id) {
processed = processed.replace(/{id}/g, tokens.id);
}
if (tokens.tag) {
processed = processed.replace(/{tag}/g, tokens.tag);
}
if (tokens.context) {
processed = processed.replace(/{context}/g, tokens.context);
}
processed = processed.replace(/{color:(\w+)}/g, (_, colorKey) => {
return resolveColor(colorKey, config, level, tag);
});
processed = processed.replace(
/{reset}/g,
config.consoleColor ? ansiColors.reset : "",
);
return processed;
} }
function parsePattern(ctx: PatternContext): string { function parsePattern(ctx: PatternContext): string {
@ -136,30 +236,33 @@ function parsePattern(ctx: PatternContext): string {
const { id, fileName, line, column, timestamp, prettyTimestamp } = const { id, fileName, line, column, timestamp, prettyTimestamp } =
getCallerInfo(config); getCallerInfo(config);
const resolvedData: string =
config.prettyPrint && typeof data === "object" && data !== null
? inspect(data, {
depth: null,
colors: config.consoleColor,
breakLength: 1,
compact: false,
})
: format(data);
const resolvedData: string = formatData(data, config);
const numericLevel: LogLevelValue = logLevelValues[level]; const numericLevel: LogLevelValue = logLevelValues[level];
const final = config.pattern const tokens: PatternTokens = {
.replace(/{timestamp}/g, timestamp) timestamp,
.replace(/{pretty-timestamp}/g, prettyTimestamp) prettyTimestamp,
.replace(/{level-name}/g, level.toUpperCase()) levelName: level.toUpperCase(),
.replace(/{level}/g, String(numericLevel)) level: String(numericLevel),
.replace(/{file-name}/g, fileName) fileName,
.replace(/{line}/g, line) line,
.replace(/{data}/g, resolvedData) column,
.replace(/{id}/g, id) data: resolvedData,
.replace(/{column}/g, column); id,
};
return replaceColorTokens(final, level, config); return processPattern(config.pattern, tokens, config, level);
} }
export { parsePattern, getCallerInfo, getTimestamp, generateShortId }; export {
parsePattern,
getCallerInfo,
getTimestamp,
generateShortId,
formatData,
getConsoleMethod,
resolveColor,
serializeLogData,
processPattern,
};

View file

@ -10,7 +10,7 @@ const logLevelValues = {
error: 50, error: 50,
fatal: 60, fatal: 60,
silent: 70, silent: 70,
}; } as const;
const defaultLevelColor: Record<LogLevel, keyof typeof ansiColors> = { const defaultLevelColor: Record<LogLevel, keyof typeof ansiColors> = {
trace: "cyan", trace: "cyan",
@ -35,7 +35,12 @@ const ansiColors: Record<string, string> = {
cyan: "\x1b[36m", cyan: "\x1b[36m",
white: "\x1b[37m", white: "\x1b[37m",
gray: "\x1b[90m", gray: "\x1b[90m",
}; bold: "\x1b[1m",
underline: "\x1b[4m",
inverse: "\x1b[7m",
hidden: "\x1b[8m",
strikethrough: "\x1b[9m",
} as const;
const defaultConfig: Required<LoggerConfig> = { const defaultConfig: Required<LoggerConfig> = {
directory: "logs", directory: "logs",
@ -43,7 +48,8 @@ const defaultConfig: Required<LoggerConfig> = {
disableFile: false, disableFile: false,
rotate: true, rotate: true,
maxFiles: 3, maxFiles: null,
fileNameFormat: "yyyy-MM-dd",
console: true, console: true,
consoleColor: true, consoleColor: true,
@ -72,12 +78,129 @@ const defaultConfig: Required<LoggerConfig> = {
prettyPrint: true, prettyPrint: true,
}; };
function isValidLogLevel(level: string): level is LogLevel {
return level in logLevelValues;
}
function isValidColor(color: string): color is keyof typeof ansiColors {
return color in ansiColors;
}
function parseNumericEnv(
value: string | undefined,
min = 0,
): number | undefined {
if (!value) return undefined;
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < min) {
return undefined;
}
return parsed;
}
function parseBooleanEnv(value: string | undefined): boolean | undefined {
if (!value) return undefined;
return value.toLowerCase() === "true";
}
function validateAndSanitizeConfig(
config: LoggerConfig,
source: string,
): LoggerConfig {
const sanitized = { ...config };
const warnings: string[] = [];
if (sanitized.level && !isValidLogLevel(sanitized.level)) {
warnings.push(
`Invalid log level "${sanitized.level}" in ${source}, using default "info"`,
);
sanitized.level = "info";
}
if (sanitized.maxFiles !== undefined && sanitized.maxFiles !== null) {
if (
typeof sanitized.maxFiles !== "number" ||
sanitized.maxFiles < 1 ||
!Number.isInteger(sanitized.maxFiles)
) {
warnings.push(
`Invalid maxFiles value "${sanitized.maxFiles}" in ${source}, setting to null`,
);
sanitized.maxFiles = null;
}
}
if (sanitized.levelColor) {
const validLevelColors: Partial<Record<LogLevel, keyof typeof ansiColors>> =
{};
for (const [level, color] of Object.entries(sanitized.levelColor)) {
if (!isValidLogLevel(level)) {
warnings.push(
`Invalid log level "${level}" in levelColor from ${source}, skipping`,
);
continue;
}
if (!isValidColor(color)) {
warnings.push(
`Invalid color "${color}" for level "${level}" in ${source}, using default`,
);
validLevelColors[level as LogLevel] =
defaultLevelColor[level as LogLevel];
} else {
validLevelColors[level as LogLevel] = color;
}
}
sanitized.levelColor = validLevelColors;
}
if (sanitized.customColors) {
const validCustomColors: Record<string, keyof typeof ansiColors> = {};
for (const [tag, color] of Object.entries(sanitized.customColors)) {
if (!isValidColor(color)) {
warnings.push(
`Invalid color "${color}" for tag "${tag}" in ${source}, skipping`,
);
continue;
}
validCustomColors[tag] = color;
}
sanitized.customColors = validCustomColors;
}
if (warnings.length > 0) {
console.warn(
`[@atums/echo] Configuration warnings:\n ${warnings.join("\n ")}`,
);
}
return sanitized;
}
function loadLoggerConfig(configPath = "logger.json"): LoggerConfig { function loadLoggerConfig(configPath = "logger.json"): LoggerConfig {
try { try {
const fullPath: string = resolve(process.cwd(), configPath); const fullPath: string = resolve(process.cwd(), configPath);
const raw: string = readFileSync(fullPath, "utf-8"); const raw: string = readFileSync(fullPath, "utf-8");
return JSON.parse(raw); const parsed = JSON.parse(raw);
} catch {
if (typeof parsed !== "object" || parsed === null) {
console.warn(`[@atums/echo] Invalid config file format: ${configPath}`);
return {};
}
return validateAndSanitizeConfig(parsed, `config file "${configPath}"`);
} catch (error) {
if (error instanceof Error && !error.message.includes("ENOENT")) {
console.warn(
`[@atums/echo] Failed to load config file ${configPath}:`,
error.message,
);
}
return {}; return {};
} }
} }
@ -85,50 +208,97 @@ function loadLoggerConfig(configPath = "logger.json"): LoggerConfig {
function loadEnvConfig(): LoggerConfig { function loadEnvConfig(): LoggerConfig {
const config: LoggerConfig = {}; const config: LoggerConfig = {};
if (process.env.LOG_LEVEL) config.level = process.env.LOG_LEVEL as LogLevel; if (process.env.LOG_LEVEL && isValidLogLevel(process.env.LOG_LEVEL)) {
if (process.env.LOG_DIRECTORY) config.directory = process.env.LOG_DIRECTORY; config.level = process.env.LOG_LEVEL;
if (process.env.LOG_DISABLE_FILE) }
config.disableFile = process.env.LOG_DISABLE_FILE === "true";
if (process.env.LOG_ROTATE) config.rotate = process.env.LOG_ROTATE === "true"; if (process.env.LOG_DIRECTORY) {
if (process.env.LOG_MAX_FILES) config.directory = process.env.LOG_DIRECTORY;
config.maxFiles = Number.parseInt(process.env.LOG_MAX_FILES, 10); }
if (process.env.LOG_CONSOLE)
config.console = process.env.LOG_CONSOLE === "true"; config.disableFile = parseBooleanEnv(process.env.LOG_DISABLE_FILE);
if (process.env.LOG_CONSOLE_COLOR) config.rotate = parseBooleanEnv(process.env.LOG_ROTATE);
config.consoleColor = process.env.LOG_CONSOLE_COLOR === "true"; config.console = parseBooleanEnv(process.env.LOG_CONSOLE);
if (process.env.LOG_DATE_FORMAT) config.consoleColor = parseBooleanEnv(process.env.LOG_CONSOLE_COLOR);
config.silent = parseBooleanEnv(process.env.LOG_SILENT);
config.prettyPrint = parseBooleanEnv(process.env.LOG_PRETTY_PRINT);
const maxFiles = parseNumericEnv(process.env.LOG_MAX_FILES, 1);
if (maxFiles !== undefined) {
config.maxFiles = maxFiles;
}
if (process.env.LOG_FILE_NAME_FORMAT) {
config.fileNameFormat = process.env.LOG_FILE_NAME_FORMAT;
}
if (process.env.LOG_DATE_FORMAT) {
config.dateFormat = process.env.LOG_DATE_FORMAT; config.dateFormat = process.env.LOG_DATE_FORMAT;
if (process.env.LOG_TIMEZONE) config.timezone = process.env.LOG_TIMEZONE; }
if (process.env.LOG_SILENT) config.silent = process.env.LOG_SILENT === "true";
if (process.env.LOG_PATTERN) config.pattern = process.env.LOG_PATTERN; if (process.env.LOG_TIMEZONE) {
if (process.env.LOG_PRETTY_PRINT) config.timezone = process.env.LOG_TIMEZONE;
config.prettyPrint = process.env.LOG_PRETTY_PRINT === "true"; }
if (process.env.LOG_PATTERN) {
config.pattern = process.env.LOG_PATTERN;
}
if (process.env.LOG_CUSTOM_PATTERN) {
config.customPattern = process.env.LOG_CUSTOM_PATTERN;
}
if (process.env.LOG_LEVEL_COLOR) { if (process.env.LOG_LEVEL_COLOR) {
const colors = process.env.LOG_LEVEL_COLOR.split(","); const colors = process.env.LOG_LEVEL_COLOR.split(",");
for (const color of colors) { const levelColor: Partial<Record<LogLevel, keyof typeof ansiColors>> = {};
const [level, colorName] = color.split(":");
if (logLevelValues[level as LogLevel] !== undefined) { for (const colorPair of colors) {
config.levelColor = { const [level, colorName] = colorPair.split(":");
...config.levelColor,
[level as LogLevel]: colorName as keyof typeof ansiColors, if (
}; level &&
colorName &&
isValidLogLevel(level) &&
isValidColor(colorName)
) {
levelColor[level] = colorName;
} else {
console.warn(`[@atums/echo] Invalid level color pair: ${colorPair}`);
} }
} }
if (Object.keys(levelColor).length > 0) {
config.levelColor = levelColor;
}
} }
if (process.env.LOG_CUSTOM_COLORS) { if (process.env.LOG_CUSTOM_COLORS) {
const colors = process.env.LOG_CUSTOM_COLORS.split(","); const colors = process.env.LOG_CUSTOM_COLORS.split(",");
for (const color of colors) { const customColors: Record<string, keyof typeof ansiColors> = {};
const [tag, colorName] = color.split(":");
config.customColors = { for (const colorPair of colors) {
...config.customColors, const [tag, colorName] = colorPair.split(":");
[tag]: colorName as keyof typeof ansiColors,
}; if (tag && colorName && isValidColor(colorName)) {
customColors[tag] = colorName;
} else {
console.warn(`[@atums/echo] Invalid custom color pair: ${colorPair}`);
}
}
if (Object.keys(customColors).length > 0) {
config.customColors = customColors;
} }
} }
return config; const sanitizedConfig = validateAndSanitizeConfig(
config,
"environment variables",
);
return Object.fromEntries(
Object.entries(sanitizedConfig).filter(([_, value]) => value !== undefined),
) as LoggerConfig;
} }
export { export {
@ -138,4 +308,7 @@ export {
loadEnvConfig, loadEnvConfig,
logLevelValues, logLevelValues,
ansiColors, ansiColors,
validateAndSanitizeConfig,
isValidLogLevel,
isValidColor,
}; };

View file

@ -7,6 +7,7 @@ import {
unlinkSync, unlinkSync,
} from "node:fs"; } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { serializeLogData } from "@lib/char";
import type { LogLevel, LoggerConfig } from "@types"; import type { LogLevel, LoggerConfig } from "@types";
import { format } from "date-fns-tz"; import { format } from "date-fns-tz";
@ -14,15 +15,30 @@ class FileLogger {
private stream: WriteStream | null = null; private stream: WriteStream | null = null;
private filePath = ""; private filePath = "";
private date = ""; private date = "";
private fileNameFormat = "yyyy-MM-dd";
constructor(private readonly config: Required<LoggerConfig>) { constructor(private readonly config: Required<LoggerConfig>) {
if (!existsSync(this.config.directory)) { if (!existsSync(this.config.directory)) {
mkdirSync(this.config.directory, { recursive: true }); mkdirSync(this.config.directory, { recursive: true });
} }
if (this.config.fileNameFormat) {
try {
format(new Date(), this.config.fileNameFormat, {
timeZone: this.config.timezone,
});
this.fileNameFormat = this.config.fileNameFormat;
} catch (error) {
throw new Error(
`[@atums/echo] Invalid fileNameFormat: ${this.config.fileNameFormat}. Error: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
} }
private getLogFilePath(dateStr: string): string { private getLogFilePath(dateStr: string): string {
return join(this.config.directory, `${dateStr}.jsonl`); const fileName = `${dateStr}.jsonl`;
return join(this.config.directory, fileName);
} }
private resetStream(path: string): void { private resetStream(path: string): void {
@ -31,13 +47,32 @@ class FileLogger {
this.filePath = path; this.filePath = path;
} }
private generateFileRegex(): RegExp {
const pattern = this.fileNameFormat
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replace(/\\y\\y\\y\\y/g, "\\d{4}")
.replace(/\\M\\M/g, "\\d{2}")
.replace(/\\d\\d/g, "\\d{2}")
.replace(/\\H\\H/g, "\\d{2}")
.replace(/\\m\\m/g, "\\d{2}")
.replace(/\\s\\s/g, "\\d{2}")
.replace(/\\S\\S\\S/g, "\\d{3}");
return new RegExp(`^${pattern}\\.jsonl$`);
}
private pruneOldLogs(): void { private pruneOldLogs(): void {
if (this.config.maxFiles && this.config.maxFiles < 1) { if (this.config.maxFiles === null) {
return;
}
if (this.config.maxFiles < 1) {
throw new Error("[@atums/echo] maxFiles must be >= 1 if set."); throw new Error("[@atums/echo] maxFiles must be >= 1 if set.");
} }
const fileRegex = this.generateFileRegex();
const files = readdirSync(this.config.directory) const files = readdirSync(this.config.directory)
.filter((file) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(file)) .filter((file) => fileRegex.test(file))
.sort(); .sort();
const excess = files.slice( const excess = files.slice(
@ -52,6 +87,13 @@ class FileLogger {
} }
} }
private getFilePath(dateStr?: string): string {
if (this.config.rotate && dateStr) {
return this.getLogFilePath(dateStr);
}
return join(this.config.directory, "log.jsonl");
}
public write( public write(
level: LogLevel | string, level: LogLevel | string,
data: unknown, data: unknown,
@ -67,27 +109,6 @@ class FileLogger {
if (this.config.disableFile) return; if (this.config.disableFile) return;
const now = new Date(); 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({ const line = `${JSON.stringify({
timestamp: new Date(meta.timestamp).getTime(), timestamp: new Date(meta.timestamp).getTime(),
level, level,
@ -95,18 +116,28 @@ class FileLogger {
file: meta.fileName, file: meta.fileName,
line: meta.line, line: meta.line,
column: meta.column, column: meta.column,
data: data: serializeLogData(data),
data instanceof Error
? {
name: data.name,
message: data.message,
stack: data.stack,
}
: typeof data === "string" || typeof data === "number"
? data
: data,
})}\n`; })}\n`;
let path: string;
const dateStr = this.config.rotate
? format(now, this.fileNameFormat, { timeZone: this.config.timezone })
: undefined;
const needsRotation = this.config.rotate && this.date !== dateStr;
path = this.getFilePath(dateStr);
if (!this.stream || needsRotation || this.filePath !== path) {
if (this.config.rotate && dateStr) {
this.date = dateStr;
}
this.resetStream(path);
if (needsRotation) {
this.pruneOldLogs();
}
}
try { try {
this.stream?.write(line); this.stream?.write(line);
} catch (err) { } catch (err) {

View file

@ -1,6 +1,6 @@
import { ansiColors, logLevelValues } from "@lib/config"; import type { ansiColors, logLevelValues } from "@lib/config";
type LogLevelValue = typeof logLevelValues[keyof typeof logLevelValues]; type LogLevelValue = (typeof logLevelValues)[keyof typeof logLevelValues];
type LogLevel = keyof typeof logLevelValues; type LogLevel = keyof typeof logLevelValues;
type LoggerConfig = { type LoggerConfig = {
@ -9,7 +9,8 @@ type LoggerConfig = {
disableFile?: boolean; disableFile?: boolean;
rotate?: boolean; rotate?: boolean;
maxFiles?: number; maxFiles?: number | null;
fileNameFormat?: string;
console?: boolean; console?: boolean;
consoleColor?: boolean; consoleColor?: boolean;
@ -28,10 +29,30 @@ type LoggerConfig = {
prettyPrint?: boolean; prettyPrint?: boolean;
}; };
interface PatternContext { type PatternContext = {
level: LogLevel; level: LogLevel;
data: unknown; data: unknown;
config: Required<LoggerConfig>; config: Required<LoggerConfig>;
};
interface PatternTokens {
timestamp?: string;
prettyTimestamp?: string;
levelName?: string;
level?: string;
fileName?: string;
line?: string;
column?: string;
data?: string;
id?: string;
tag?: string;
context?: string;
} }
export type { LogLevel, LogLevelValue, LoggerConfig, PatternContext }; export type {
LogLevel,
LogLevelValue,
LoggerConfig,
PatternContext,
PatternTokens,
};