add file logging, readme, so on
All checks were successful
Code quality checks / biome (push) Successful in 8s

This commit is contained in:
creations 2025-05-24 16:25:02 -04:00
parent 542beb82a4
commit 499b8ec46d
Signed by: creations
GPG key ID: 8F553AA4320FC711
6 changed files with 360 additions and 24 deletions

181
README.md Normal file
View 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)

View file

@ -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 {

View file

@ -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 };

View file

@ -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
View 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();
});

View file

@ -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 {