From 499b8ec46dd0c411e140f911fcde0cab05c6ab63 Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 24 May 2025 16:25:02 -0400 Subject: [PATCH] add file logging, readme, so on --- README.md | 181 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 8 +- src/lib/char.ts | 69 +++++++++++++----- src/lib/config.ts | 18 ++++- src/lib/file.ts | 105 +++++++++++++++++++++++++++ types/index.ts | 3 +- 6 files changed, 360 insertions(+), 24 deletions(-) create mode 100644 README.md create mode 100644 src/lib/file.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..35611bd --- /dev/null +++ b/README.md @@ -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) diff --git a/src/index.ts b/src/index.ts index c43d134..3a29308 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { diff --git a/src/lib/char.ts b/src/lib/char.ts index 504e3bc..131e7e1 100644 --- a/src/lib/char.ts +++ b/src/lib/char.ts @@ -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): string { +function getTimestamp(config: Required): { + 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): { + 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): { 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): { } 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 }; diff --git a/src/lib/config.ts b/src/lib/config.ts index 3b19195..6f5887c 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -12,6 +12,16 @@ const logLevelValues = { silent: 70, }; +const defaultLevelColor: Record = { + trace: "cyan", + debug: "blue", + info: "green", + warn: "yellow", + error: "red", + fatal: "red", + silent: "gray", +}; + const ansiColors: Record = { reset: "\x1b[0m", dim: "\x1b[2m", @@ -33,7 +43,6 @@ const defaultConfig: Required = { disableFile: false, rotate: true, - maxSizeMB: 5, maxFiles: 3, console: true, @@ -55,6 +64,8 @@ const defaultConfig: Required = { 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, diff --git a/src/lib/file.ts b/src/lib/file.ts new file mode 100644 index 0000000..3eb8414 --- /dev/null +++ b/src/lib/file.ts @@ -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, + 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, +): 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(); +}); diff --git a/types/index.ts b/types/index.ts index 03095c2..27e9fb1 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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>; + + prettyPrint?: boolean; }; interface PatternContext {