- added fileNameFormat
All checks were successful
Code quality checks / biome (push) Successful in 11s
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:
parent
5c88ce3e28
commit
f7d2c7084b
8 changed files with 491 additions and 151 deletions
17
README.md
17
README.md
|
@ -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:
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
65
src/index.ts
65
src/index.ts
|
@ -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);
|
||||||
|
|
173
src/lib/char.ts
173
src/lib/char.ts
|
@ -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,
|
||||||
|
};
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue