add file logging, readme, so on
All checks were successful
Code quality checks / biome (push) Successful in 8s
All checks were successful
Code quality checks / biome (push) Successful in 8s
This commit is contained in:
parent
542beb82a4
commit
499b8ec46d
6 changed files with 360 additions and 24 deletions
181
README.md
Normal file
181
README.md
Normal file
|
@ -0,0 +1,181 @@
|
|||
# @atums/echo
|
||||
|
||||
A minimal, flexible logger for Node with:
|
||||
|
||||
- Colored console output
|
||||
- Daily `.jsonl` file logging
|
||||
- Configurable output patterns
|
||||
- Structured logs with caller metadata
|
||||
- Fully typed config with environment/file/constructor override
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- Console and file logging with level-based filtering
|
||||
- Colored output with ANSI formatting
|
||||
- Daily rotated `.jsonl` files
|
||||
- Supports runtime configuration merging
|
||||
- Auto-formatted output using custom patterns
|
||||
- Includes caller file, line, and column
|
||||
- Pretty-prints structured objects if enabled
|
||||
- Flushes open file streams on exit
|
||||
- Uses Biome and EditorConfig for formatting and linting
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @atums/echo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { echo } from "@atums/echo";
|
||||
|
||||
echo.info("App started");
|
||||
echo.debug({ state: "init", ok: true });
|
||||
|
||||
try {
|
||||
throw new Error("Something failed");
|
||||
} catch (err) {
|
||||
echo.error(err);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Logger config can be defined in three ways:
|
||||
|
||||
1. JSON file (e.g. `logger.json`)
|
||||
2. Environment variables
|
||||
3. Constructor override
|
||||
|
||||
Priority (highest to lowest):
|
||||
|
||||
```
|
||||
constructor > environment > logger.json > defaults
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### logger.json example
|
||||
|
||||
```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}"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Supported Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|------------------------|------------------------------------------|
|
||||
| `LOG_LEVEL` | Log level (`debug`, `info`, etc.) |
|
||||
| `LOG_DIRECTORY` | Log directory path (default: `logs`) |
|
||||
| `LOG_DISABLE_FILE` | Disable file output (`true` or `false`) |
|
||||
| `LOG_ROTATE` | Enable daily rotation |
|
||||
| `LOG_MAX_FILES` | Max rotated files to keep |
|
||||
| `LOG_CONSOLE` | Enable console output |
|
||||
| `LOG_CONSOLE_COLOR` | Enable ANSI color in console output |
|
||||
| `LOG_DATE_FORMAT` | Date format for display timestamp |
|
||||
| `LOG_TIMEZONE` | Timezone (`local` or IANA string) |
|
||||
| `LOG_SILENT` | Completely disable output |
|
||||
| `LOG_PATTERN` | Custom log format for console |
|
||||
| `LOG_PRETTY_PRINT` | Pretty-print objects in console output |
|
||||
|
||||
---
|
||||
|
||||
## Pattern Tokens
|
||||
|
||||
These tokens are replaced in the log pattern:
|
||||
|
||||
| Token | Description |
|
||||
|---------------|-----------------------------------------|
|
||||
| `{timestamp}` | Formatted display timestamp |
|
||||
| `{level-name}`| Uppercase log level (e.g. DEBUG) |
|
||||
| `{level}` | Numeric log level |
|
||||
| `{file-name}` | Source filename |
|
||||
| `{line}` | Line number in source |
|
||||
| `{column}` | Column number in source |
|
||||
| `{data}` | Formatted log data (message/object) |
|
||||
| `{id}` | Unique short ID for the log |
|
||||
| `{color:*}` | ANSI color start (e.g. `{color:red}`) |
|
||||
| `{reset}` | Resets console color |
|
||||
|
||||
---
|
||||
|
||||
## Output Examples
|
||||
|
||||
### Console (with colors)
|
||||
|
||||
```
|
||||
2025-05-24 16:15:00.000 [INFO] (index.ts:3:6) Server started
|
||||
```
|
||||
|
||||
### File (`logs/2025-05-24.jsonl`)
|
||||
|
||||
Each line is structured JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": 1748115300000,
|
||||
"level": "info",
|
||||
"id": "aB4cD9xZ",
|
||||
"file": "index.ts",
|
||||
"line": "3",
|
||||
"column": "6",
|
||||
"data": "Server started"
|
||||
}
|
||||
```
|
||||
|
||||
If an error is logged:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": 1748115301000,
|
||||
"level": "error",
|
||||
"id": "qW3eR7tU",
|
||||
"file": "index.ts",
|
||||
"line": "10",
|
||||
"column": "12",
|
||||
"data": {
|
||||
"name": "Error",
|
||||
"message": "Something failed",
|
||||
"stack": "Error: Something failed\n at index.ts:10:12"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
This project uses:
|
||||
|
||||
- TypeScript
|
||||
- Bun runtime
|
||||
- Biome for formatting/linting
|
||||
- JSONL for structured file output
|
||||
- `date-fns-tz` for timezone support
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
BSD 3-Clause [License](License)
|
|
@ -7,13 +7,14 @@ import {
|
|||
statSync,
|
||||
} from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { parsePattern } from "@lib/char";
|
||||
import { getCallerInfo, parsePattern } from "@lib/char";
|
||||
import {
|
||||
defaultConfig,
|
||||
loadEnvConfig,
|
||||
loadLoggerConfig,
|
||||
logLevelValues,
|
||||
} from "@lib/config";
|
||||
import { writeLogJson } from "@lib/file";
|
||||
import type { LogLevel, LoggerConfig } from "@types";
|
||||
|
||||
class Echo {
|
||||
|
@ -73,6 +74,7 @@ class Echo {
|
|||
)
|
||||
return;
|
||||
|
||||
const meta = getCallerInfo(this.config);
|
||||
const line = parsePattern({ level, data, config: this.config });
|
||||
|
||||
if (this.config.console) {
|
||||
|
@ -80,6 +82,10 @@ class Echo {
|
|||
line,
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.config.disableFile) {
|
||||
writeLogJson(level, data, meta, this.config);
|
||||
}
|
||||
}
|
||||
|
||||
public debug(data: unknown): void {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { basename } from "node:path";
|
||||
import { format } from "node:util";
|
||||
import { format, inspect } from "node:util";
|
||||
import { format as formatDate } from "date-fns-tz";
|
||||
|
||||
import { ansiColors, logLevelValues } from "@lib/config";
|
||||
import { ansiColors, defaultLevelColor, logLevelValues } from "@lib/config";
|
||||
import type {
|
||||
LogLevel,
|
||||
LogLevelValue,
|
||||
|
@ -10,28 +10,43 @@ import type {
|
|||
PatternContext,
|
||||
} from "@types";
|
||||
|
||||
function getTimestamp(config: Required<LoggerConfig>): string {
|
||||
function getTimestamp(config: Required<LoggerConfig>): {
|
||||
prettyTimestamp: string;
|
||||
timestamp: string;
|
||||
} {
|
||||
const now = new Date();
|
||||
|
||||
if (config.timezone === "local") {
|
||||
return formatDate(now, config.dateFormat);
|
||||
return {
|
||||
prettyTimestamp: formatDate(now, config.dateFormat),
|
||||
timestamp: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return formatDate(now, config.dateFormat, {
|
||||
timeZone: config.timezone,
|
||||
});
|
||||
return {
|
||||
prettyTimestamp: formatDate(now, config.dateFormat, {
|
||||
timeZone: config.timezone,
|
||||
}),
|
||||
timestamp: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function getCallerInfo(config: Required<LoggerConfig>): {
|
||||
id: string;
|
||||
fileName: string;
|
||||
line: string;
|
||||
column: string;
|
||||
timestamp: string;
|
||||
prettyTimestamp: string;
|
||||
} {
|
||||
const id = generateShortId();
|
||||
|
||||
const fallback = {
|
||||
id: id,
|
||||
fileName: "unknown",
|
||||
line: "0",
|
||||
timestamp: getTimestamp(config),
|
||||
timestamp: getTimestamp(config).timestamp,
|
||||
prettyTimestamp: getTimestamp(config).prettyTimestamp,
|
||||
column: "0",
|
||||
};
|
||||
|
||||
|
@ -54,10 +69,12 @@ function getCallerInfo(config: Required<LoggerConfig>): {
|
|||
const columnNumber = fileURLMatch[3];
|
||||
|
||||
return {
|
||||
id: id,
|
||||
fileName: basename(fullPath),
|
||||
line: lineNumber,
|
||||
column: columnNumber,
|
||||
timestamp: getTimestamp(config),
|
||||
timestamp: getTimestamp(config).timestamp,
|
||||
prettyTimestamp: getTimestamp(config).prettyTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -76,10 +93,12 @@ function getCallerInfo(config: Required<LoggerConfig>): {
|
|||
}
|
||||
|
||||
return {
|
||||
id: id,
|
||||
fileName: basename(fullPath),
|
||||
line: lineNumber,
|
||||
column: columnNumber,
|
||||
timestamp: getTimestamp(config),
|
||||
timestamp: getTimestamp(config).timestamp,
|
||||
prettyTimestamp: getTimestamp(config).prettyTimestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -107,33 +126,45 @@ function replaceColorTokens(
|
|||
): string {
|
||||
return input
|
||||
.replace(/{color:(\w+)}/g, (_, colorKey) => {
|
||||
if (!config.consoleColor) return "";
|
||||
if (colorKey === "levelColor") {
|
||||
const colorForLevel = config.levelColor?.[level];
|
||||
const colorForLevel =
|
||||
config.levelColor?.[level] ?? defaultLevelColor[level];
|
||||
return ansiColors[colorForLevel ?? ""] ?? "";
|
||||
}
|
||||
return ansiColors[colorKey] ?? "";
|
||||
})
|
||||
.replace(/{reset}/g, ansiColors.reset);
|
||||
.replace(/{reset}/g, config.consoleColor ? ansiColors.reset : "");
|
||||
}
|
||||
|
||||
function parsePattern(ctx: PatternContext): string {
|
||||
const { level, data, config } = ctx;
|
||||
|
||||
const { fileName, line, column, timestamp } = getCallerInfo(config);
|
||||
const resolvedData: string = format(data);
|
||||
const { id, fileName, line, column, timestamp, prettyTimestamp } =
|
||||
getCallerInfo(config);
|
||||
const resolvedData: string =
|
||||
config.prettyPrint && typeof data === "object" && data !== null
|
||||
? inspect(data, {
|
||||
depth: null,
|
||||
colors: false,
|
||||
breakLength: 1,
|
||||
compact: false,
|
||||
})
|
||||
: format(data);
|
||||
|
||||
const numericLevel: LogLevelValue = logLevelValues[level];
|
||||
|
||||
const final: string = config.pattern
|
||||
.replace(/{timestamp}/g, timestamp)
|
||||
const final = config.pattern
|
||||
.replace(/{timestamp}/g, config.prettyPrint ? prettyTimestamp : timestamp)
|
||||
.replace(/{level-name}/g, level.toUpperCase())
|
||||
.replace(/{level}/g, String(numericLevel))
|
||||
.replace(/{file-name}/g, fileName)
|
||||
.replace(/{line}/g, line)
|
||||
.replace(/{data}/g, resolvedData)
|
||||
.replace(/{id}/g, generateShortId())
|
||||
.replace(/{id}/g, id)
|
||||
.replace(/{column}/g, column);
|
||||
|
||||
return config.consoleColor ? replaceColorTokens(final, level, config) : final;
|
||||
return replaceColorTokens(final, level, config);
|
||||
}
|
||||
|
||||
export { parsePattern };
|
||||
export { parsePattern, getCallerInfo };
|
||||
|
|
|
@ -12,6 +12,16 @@ const logLevelValues = {
|
|||
silent: 70,
|
||||
};
|
||||
|
||||
const defaultLevelColor: Record<LogLevel, keyof typeof ansiColors> = {
|
||||
trace: "cyan",
|
||||
debug: "blue",
|
||||
info: "green",
|
||||
warn: "yellow",
|
||||
error: "red",
|
||||
fatal: "red",
|
||||
silent: "gray",
|
||||
};
|
||||
|
||||
const ansiColors: Record<string, string> = {
|
||||
reset: "\x1b[0m",
|
||||
dim: "\x1b[2m",
|
||||
|
@ -33,7 +43,6 @@ const defaultConfig: Required<LoggerConfig> = {
|
|||
disableFile: false,
|
||||
|
||||
rotate: true,
|
||||
maxSizeMB: 5,
|
||||
maxFiles: 3,
|
||||
|
||||
console: true,
|
||||
|
@ -55,6 +64,8 @@ const defaultConfig: Required<LoggerConfig> = {
|
|||
error: "red",
|
||||
fatal: "red",
|
||||
},
|
||||
|
||||
prettyPrint: true,
|
||||
};
|
||||
|
||||
function loadLoggerConfig(configPath = "logger.json"): LoggerConfig {
|
||||
|
@ -75,8 +86,6 @@ function loadEnvConfig(): LoggerConfig {
|
|||
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_MAX_SIZE_MB)
|
||||
config.maxSizeMB = Number.parseInt(process.env.LOG_MAX_SIZE_MB, 10);
|
||||
if (process.env.LOG_MAX_FILES)
|
||||
config.maxFiles = Number.parseInt(process.env.LOG_MAX_FILES, 10);
|
||||
if (process.env.LOG_CONSOLE)
|
||||
|
@ -88,12 +97,15 @@ function loadEnvConfig(): LoggerConfig {
|
|||
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_PRETTY_PRINT)
|
||||
config.prettyPrint = process.env.LOG_PRETTY_PRINT === "true";
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export {
|
||||
defaultConfig,
|
||||
defaultLevelColor,
|
||||
loadLoggerConfig,
|
||||
loadEnvConfig,
|
||||
logLevelValues,
|
||||
|
|
105
src/lib/file.ts
Normal file
105
src/lib/file.ts
Normal file
|
@ -0,0 +1,105 @@
|
|||
import {
|
||||
type WriteStream,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
} 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 = "";
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
let filePath: string;
|
||||
|
||||
if (config.rotate) {
|
||||
const dateStr = format(now, "yyyy-MM-dd", {
|
||||
timeZone: config.timezone,
|
||||
});
|
||||
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();
|
||||
});
|
|
@ -9,7 +9,6 @@ type LoggerConfig = {
|
|||
disableFile?: boolean;
|
||||
|
||||
rotate?: boolean;
|
||||
maxSizeMB?: number;
|
||||
maxFiles?: number;
|
||||
|
||||
console?: boolean;
|
||||
|
@ -22,6 +21,8 @@ type LoggerConfig = {
|
|||
|
||||
pattern?: string;
|
||||
levelColor?: Partial<Record<LogLevel, keyof typeof ansiColors>>;
|
||||
|
||||
prettyPrint?: boolean;
|
||||
};
|
||||
|
||||
interface PatternContext {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue