Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • dev
  • main
  • v1.0.2
  • v1.0.3
  • v1.0.4
  • v1.0.5
  • v1.0.6
  • v1.0.7
  • v1.0.8
  • v2.0.0
10 results

Target

Select target project
  • atums/echo
1 result
Select Git revision
  • dev
  • main
  • v1.0.2
  • v1.0.3
  • v1.0.4
  • v1.0.5
  • v1.0.6
  • v1.0.7
  • v1.0.8
  • v2.0.0
10 results
Show changes
Commits on Source (23)
name: Code quality checks
on:
push:
pull_request:
jobs:
biome:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Bun
run: |
curl -fsSL https://bun.sh/install | bash
export BUN_INSTALL="$HOME/.bun"
echo "$BUN_INSTALL/bin" >> $GITHUB_PATH
- name: Install Dependencies
run: bun install
- name: Run Biome with verbose output
run: bunx biome ci . --verbose
stages:
- quality
biome:
image:
name: ghcr.io/biomejs/biome:latest
entrypoint: [""]
stage: quality
script:
- biome ci --verbose
- biome ci --reporter=gitlab --colors=off > /tmp/code-quality.json
- cp /tmp/code-quality.json code-quality.json
artifacts:
reports:
codequality: code-quality.json
paths:
- code-quality.json
rules:
- if: $CI_COMMIT_BRANCH
- if: $CI_MERGE_REQUEST_ID
# @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
---
A minimal, flexible logger for Node with console and file output, ANSI colors, and daily rotation.
## Installation
......@@ -30,229 +8,72 @@ A minimal, flexible logger for Node with:
bun add @atums/echo
```
---
## Usage
## Quick Start
```ts
import { echo } from "@atums/echo";
// Basic logging
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",
"disableFile": false,
"rotate": true,
"maxFiles": 3,
"console": true,
"consoleColor": true,
"dateFormat": "yyyy-MM-dd HH:mm:ss.SSS",
"timezone": "local",
"silent": false,
"pattern": "{color:gray}{timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{line}:{column}{color:gray}){reset} {data}",
"levelColor": {
"debug": "blue",
"info": "green",
"warn": "yellow",
"error": "red",
"fatal": "red"
},
"customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}",
"customColors": {
"GET": "red",
"POST": "blue",
"PUT": "yellow",
"DELETE": "red",
"PATCH": "cyan",
"HEAD": "magenta",
"OPTIONS": "white",
"TRACE": "gray"
},
"prettyPrint": true
}
```
---
### Supported Environment Variables
| Variable | Description |
|------------------------|-----------------------------------------------|
| `LOG_LEVEL` | Log level (`debug`, `info`, etc.) |
| `LOG_LEVEL_COLOR` | Comma-separated list of `TAG:color` pairs |
| `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 |
| `LOG_CUSTOM_PATTERN` | Pattern used for `echo.custom()` logs |
| `LOG_CUSTOM_COLORS` | Comma-separated list of `TAG:color` pairs |
---
## Pattern Tokens
These tokens are replaced in the log pattern:
| Token | Description |
|----------------------|-------------------------------------------------|
| `{timestamp}` | ISO timestamp string |
| `{pretty-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 |
| `{tag}` | Custom tag used in `echo.custom()` |
| `{context}` | Custom context in `echo.custom()` |
| `{color:*}` | ANSI color start (e.g. `{color:red}`) |
| `{color:levelColor}` | Dynamic color based on log level |
| `{color:tagColor}` | Color for custom tag |
| `{color:contextColor}`| Color for custom context |
| `{reset}` | Resets console color |
---
echo.error("Something failed:", error, { userId: 123 });
## Custom Log Entries
// Multiple arguments
echo.warn("Rate limit:", 429, { endpoint: "/api/users" });
You can log arbitrary tagged messages with `echo.custom(tag, context, message)`:
```ts
echo.custom("GET", "/health", { status: 200 });
// Custom tagged logs
echo.custom("GET", "/health", { status: 200, duration: "15ms" });
```
The output format is controlled by:
## Log Levels
- `customPattern` — e.g. `{pretty-timestamp} [GET] (/health) { status: 200 }`
- `customColors` — define colors for tags like `"GET": "green"`
`trace` | `debug` | `info` | `warn` | `error` | `fatal`
### Example output
## Output
**Console:**
```
2025-05-24 16:22:00.123 [GET] (/health) { status: 200 }
2025-05-24 16:15:00.000 [INFO] (app.ts:3:6) Server started
```
---
## Output Examples
### Console
```
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:
**File (`logs/2025-05-24.jsonl`):**
```json
{
"timestamp": 1748115300000,
"level": "info",
"id": "aB4cD9xZ",
"file": "index.ts",
"file": "app.ts",
"line": "3",
"column": "6",
"data": "Server started"
"data": ["Server started"]
}
```
If an error is logged:
## Configuration
Configure via `logger.json`, environment variables, or constructor. See - [Configuration Guide](https://heliopolis.live/atums/echo/-/blob/main/configuration.md) for full details.
**Quick setup:**
```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"
}
"level": "debug",
"directory": "logs",
"console": true,
"consoleColor": true
}
```
---
## Development
This project uses:
## Documentation
- TypeScript
- Bun runtime
- Biome for formatting/linting
- JSONL for structured file output
- `date-fns-tz` for timezone support
- - [Configuration Guide](https://heliopolis.live/atums/echo/-/blob/main/configuration.md) - Complete config options, environment variables, and patterns
---
## Images
<details>
<summary>Logger preview (pretty)</summary>
![Logger preview](https://git.creations.works/atums/echo/media/branch/main/demo/image.png)
</details>
<details>
<summary>Logger preview (no pretty)</summary>
![Logger preview no-pretty](https://git.creations.works/atums/echo/media/branch/main/demo/image-no-pretty.png)
</details>
## Features
---
- Multiple arguments per log call
- Colored console output with ANSI codes
- Daily rotated `.jsonl` files
- Safe circular reference handling
- Error object serialization
- Custom log patterns and colors
- Caller location tracking
## License
BSD 3-Clause [License](License)
[BSD 3-Clause](LICENSE)
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
......@@ -7,16 +7,14 @@
},
"files": {
"ignoreUnknown": true,
"ignore": ["dist", "types"]
"includes": ["**", "!**/dist"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineEnding": "lf"
},
"organizeImports": {
"enabled": true
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"css": {
"formatter": {
"indentStyle": "tab",
......@@ -29,21 +27,32 @@
"recommended": true,
"correctness": {
"noUnusedImports": "error",
"useJsxKeyInIterable": "off",
"noUnusedVariables": "error"
},
"suspicious": {
"noVar": "error"
},
"style": {
"useConst": "error",
"noVar": "error"
}
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
}
},
"includes": ["**", "!**/types"]
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"indentStyle": "tab",
"lineEnding": "lf",
"jsxQuoteStyle": "double",
"semicolons": "always"
}
}
......
# Configuration Guide
Configuration priority (highest to lowest):
```
constructor > environment variables > logger.json > defaults
```
## Complete Configuration Example
**logger.json:**
```json
{
"directory": "logs",
"level": "debug",
"disableFile": false,
"rotate": true,
"maxFiles": 3,
"fileNameFormat": "yyyy-MM-dd",
"subDirectory": null,
"console": true,
"consoleColor": true,
"dateFormat": "yyyy-MM-dd HH:mm:ss.SSS",
"timezone": "local",
"silent": false,
"pattern": "{color:gray}{timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{line}:{column}{color:gray}){reset} {data}",
"customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}",
"levelColor": {
"debug": "blue",
"info": "green",
"warn": "yellow",
"error": "red",
"fatal": "red"
},
"tagColors": {
"GET": "green",
"POST": "blue",
"PUT": "yellow",
"DELETE": "red",
"PATCH": "cyan"
},
"prettyPrint": true
}
```
## All Configuration Options
| Option | Type | Environment Variable | Default | Description |
|--------|------|---------------------|---------|-------------|
| `level` | string | `LOG_LEVEL` | `info` | Log level filtering |
| `directory` | string | `LOG_DIRECTORY` | `logs` | Directory for log files |
| `disableFile` | boolean | `LOG_DISABLE_FILE` | `false` | Disable file output |
| `rotate` | boolean | `LOG_ROTATE` | `true` | Enable daily file rotation |
| `maxFiles` | number | `LOG_MAX_FILES` | `7` | Max rotated files to keep |
| `fileNameFormat` | string | `LOG_FILE_NAME_FORMAT` | `yyyy-MM-dd` | File name date format |
| `subDirectory` | string | `LOG_SUB_DIRECTORY` | `null` | Subdirectory for logs |
| `console` | boolean | `LOG_CONSOLE` | `true` | Enable console output |
| `consoleColor` | boolean | `LOG_CONSOLE_COLOR` | `true` | Enable ANSI colors |
| `dateFormat` | string | `LOG_DATE_FORMAT` | `yyyy-MM-dd HH:mm:ss.SSS` | Display timestamp format |
| `timezone` | string | `LOG_TIMEZONE` | `local` | Timezone (local or IANA) |
| `silent` | boolean | `LOG_SILENT` | `false` | Disable all output |
| `pattern` | string | `LOG_PATTERN` | See below | Console log format |
| `customPattern` | string | `LOG_CUSTOM_PATTERN` | See below | Format for `echo.custom()` |
| `prettyPrint` | boolean | `LOG_PRETTY_PRINT` | `true` | Pretty-print objects |
| `levelColor` | object | `LOG_LEVEL_COLOR` | See above | Level-to-color mapping |
| `tagColors` | object | `LOG_TAG_COLORS` | See above | Tag-to-color mapping |
### Environment Variable Format
**.env file:**
```env
LOG_LEVEL=debug
LOG_DIRECTORY=./app-logs
LOG_CONSOLE_COLOR=true
# Color mappings (comma-separated key:value pairs)
LOG_LEVEL_COLOR=debug:blue,info:green,warn:yellow,error:red
LOG_TAG_COLORS=GET:green,POST:blue,DELETE:red
```
## Pattern Tokens
| Token | Output Example | Description |
|-------|----------------|-------------|
| `{timestamp}` | `2025-05-24T16:15:00.000Z` | ISO timestamp |
| `{pretty-timestamp}` | `2025-05-24 16:15:00.000` | Formatted timestamp |
| `{level-name}` | `INFO` | Uppercase log level |
| `{level}` | `2` | Numeric log level |
| `{file-name}` | `app.ts` | Source filename |
| `{line}` | `42` | Line number |
| `{column}` | `12` | Column number |
| `{data}` | `Server started` | Log message/data |
| `{id}` | `aB4cD9xZ` | Unique short ID |
| `{tag}` | `GET` | Custom tag (for `echo.custom()`) |
| `{context}` | `/health` | Custom context |
| `{color:red}` | *starts red* | ANSI color start |
| `{color:levelColor}` | *dynamic* | Color based on log level |
| `{color:tagColor}` | *dynamic* | Color based on tag |
| `{reset}` | *resets color* | Reset ANSI colors |
### Pattern Examples
**Default:**
```
{color:gray}{timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{line}:{column}{color:gray}){reset} {data}
```
`2025-05-24T16:15:00.000Z [INFO] (app.ts:3:6) Server started`
**Simple:**
```
{pretty-timestamp} [{level-name}] {data}
```
`2025-05-24 16:15:00.000 [INFO] Server started`
**Minimal:**
```
[{level-name}] {data}
```
`[INFO] Server started`
## File Naming
| Format | Output |
|--------|--------|
| `yyyy-MM-dd` | `2025-06-03.jsonl` |
| `yyyy-MM-dd_HH-mm` | `2025-06-03_18-30.jsonl` |
| `yyyyMMdd` | `20250603.jsonl` |
## Constructor Override
```ts
import { Echo } from "@atums/echo";
const logger = new Echo({
level: "debug",
directory: "./custom-logs",
consoleColor: false,
pattern: "[{level-name}] {data}"
});
```
## Available Colors
`black` | `red` | `green` | `yellow` | `blue` | `magenta` | `cyan` | `white` | `gray`
Plus: `bright*`, `bold*`, `bg*` variants
demo/image-no-pretty.png

17 KiB

demo/image.png

32.5 KiB

{
"name": "@atums/echo",
"version": "1.0.2",
"version": "2.0.0",
"description": "A minimal, flexible logger",
"private": false,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"typesVersions": {
"*": {
"lib/char": ["./dist/lib/char.d.ts"],
"lib/config": ["./dist/lib/config.d.ts"]
}
},
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js"
},
"./lib/char": {
"import": "./dist/lib/char.js",
"require": "./dist/lib/char.js"
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./lib/config": {
"import": "./dist/lib/config.js",
"require": "./dist/lib/config.js"
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
}
},
"scripts": {
"dev": "bun run build --watch",
"build": "rm -rf dist && tsup src/index.ts --dts --out-dir dist --format esm,cjs",
"dev": "bun run --watch build",
"build": "rm -rf dist && tsup && rm -f dist/*.d.cts dist/**/*.d.cts",
"lint": "bunx biome check",
"lint:fix": "bunx biome check --fix",
"cleanup": "rm -rf logs node_modules bun.lock"
},
"license": "BSD-3-Clause",
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "^1.2.13",
"tsup": "^8.5.0",
"typescript": "^5.8.3"
"@biomejs/biome": "^2.0.6",
"@types/bun": "latest",
"tsup": "latest"
},
"files": ["dist", "README.md", "LICENSE"],
"files": [
"dist",
"README.md",
"LICENSE",
"configuration.md"
],
"repository": {
"type": "git",
"url": "https://git.creations.works/atums/echo"
"url": "https://heliopolis.live/atums/echo"
},
"keywords": [
"logger",
"logging",
"typescript",
"nodejs",
"structured"
],
"author": "creations.works",
"homepage": "https://heliopolis.live/atums/echo",
"bugs": {
"url": "https://heliopolis.live/atums/echo/issues"
},
"dependencies": {
"date-fns-tz": "^3.2.0"
"date-fns-tz": "latest"
}
}
import {
constants,
type Stats,
accessSync,
constants,
existsSync,
mkdirSync,
type Stats,
statSync,
} from "node:fs";
import { resolve } from "node:path";
import { format, inspect } from "node:util";
import { getCallerInfo, getTimestamp, parsePattern } from "@lib/char";
import {
ansiColors,
formatData,
getCallerInfo,
getConsoleMethod,
getTimestamp,
parsePattern,
processPattern,
} from "#lib/char";
import {
defaultConfig,
loadEnvConfig,
loadLoggerConfig,
logLevelValues,
} from "@lib/config";
import { FileLogger } from "@lib/file";
import type { LogLevel, LoggerConfig } from "@types";
validateAndSanitizeConfig,
} from "#lib/config";
import { FileLogger } from "#lib/file";
import type { LoggerConfig, LogLevel, PatternTokens } from "#types";
class Echo {
private readonly directory: string;
......@@ -35,12 +42,18 @@ class Echo {
const envConfig: LoggerConfig = loadEnvConfig();
this.config = {
const mergedConfig = {
...defaultConfig,
...fileConfig,
...envConfig,
...overrideConfig,
};
const finalConfig = validateAndSanitizeConfig(
mergedConfig,
"merged configuration",
);
this.config = finalConfig as Required<LoggerConfig>;
this.directory = resolve(this.config.directory);
......@@ -64,7 +77,7 @@ class Echo {
accessSync(dir, constants.W_OK);
}
private log(level: LogLevel, data: unknown): void {
private log(level: LogLevel, ...args: unknown[]): void {
if (
this.config.silent ||
logLevelValues[this.config.level] > logLevelValues[level]
......@@ -72,80 +85,59 @@ class Echo {
return;
const meta = getCallerInfo(this.config);
const line = parsePattern({ level, data, config: this.config });
const line = parsePattern({ level, data: args, config: this.config });
if (this.config.console) {
console[level === "error" ? "error" : level === "warn" ? "warn" : "log"](
line,
);
console[getConsoleMethod(level)](line);
}
if (!this.config.disableFile && this.fileLogger) {
this.fileLogger.write(level, data, meta);
this.fileLogger.write(level, args, meta);
}
}
public debug(data: unknown): void {
this.log("debug", data);
public debug(...args: unknown[]): void {
this.log("debug", ...args);
}
public info(data: unknown): void {
this.log("info", data);
public info(...args: unknown[]): void {
this.log("info", ...args);
}
public warn(data: unknown): void {
this.log("warn", data);
public warn(...args: unknown[]): void {
this.log("warn", ...args);
}
public error(data: unknown): void {
this.log("error", data);
public error(...args: unknown[]): void {
this.log("error", ...args);
}
public fatal(data: unknown): void {
this.log("fatal", data);
public fatal(...args: unknown[]): void {
this.log("fatal", ...args);
}
public trace(data: unknown): void {
this.log("trace", data);
public trace(...args: unknown[]): void {
this.log("trace", ...args);
}
public custom(tag: string, context: string, data: unknown): void {
if (this.config.silent) return;
const timestamps = getTimestamp(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 resolvedData = formatData(data, this.config);
const pattern =
this.config.customPattern ??
"{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}";
const line = pattern
.replace(/{timestamp}/g, timestamps.timestamp)
.replace(/{pretty-timestamp}/g, timestamps.prettyTimestamp)
.replace(/{tag}/g, tag)
.replace(/{context}/g, context)
.replace(/{data}/g, resolvedData)
.replace(/{color:gray}/g, gray)
.replace(/{color:tagColor}/g, tagColor)
.replace(/{color:contextColor}/g, contextColor)
.replace(/{reset}/g, reset);
const tokens: PatternTokens = {
timestamp: timestamps.timestamp,
prettyTimestamp: timestamps.prettyTimestamp,
tag,
context,
data: resolvedData,
};
const line = processPattern(pattern, tokens, this.config, undefined, tag);
if (this.config.console) {
console.log(line);
......@@ -168,4 +160,3 @@ function createLogger(config?: string | LoggerConfig): Echo {
const echo = new Echo();
export { echo, Echo, createLogger };
export type { LoggerConfig, LogLevel } from "@types";
import { basename } from "node:path";
import { format, inspect } from "node:util";
import { format as formatDate } from "date-fns-tz";
import { ansiColors, defaultLevelColor, logLevelValues } from "#lib/config";
import { ansiColors, defaultLevelColor, logLevelValues } from "@lib/config";
import type {
LoggerConfig,
LogLevel,
LogLevelValue,
LoggerConfig,
PatternContext,
} from "@types";
PatternTokens,
} from "#types";
function getTimestamp(config: Required<LoggerConfig>): {
prettyTimestamp: string;
......@@ -40,7 +41,6 @@ function getCallerInfo(config: Required<LoggerConfig>): {
prettyTimestamp: string;
} {
const id = generateShortId();
const timestampInfo = getTimestamp(config);
const fallback = {
id,
......@@ -52,25 +52,26 @@ function getCallerInfo(config: Required<LoggerConfig>): {
};
const stack = new Error().stack;
if (!stack) {
return fallback;
}
if (!stack) return fallback;
const lines = stack.split("\n");
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
// try file:// URLs first (works on all platforms)
const fileURLMatch = line.match(
/at\s+(?:.*\()?file:\/\/(.*):(\d+):(\d+)\)?/,
);
if (fileURLMatch) {
const fullPath = fileURLMatch[1];
const lineNumber = fileURLMatch[2];
const columnNumber = fileURLMatch[3];
const [_, fullPath, lineNumber, columnNumber] = fileURLMatch;
const isInternal =
fullPath.includes("atums.echo") || fullPath.includes("@atums/echo");
if (isInternal) continue;
return {
id: id,
id,
fileName: basename(fullPath),
line: lineNumber,
column: columnNumber,
......@@ -79,22 +80,16 @@ function getCallerInfo(config: Required<LoggerConfig>): {
};
}
const rawMatch = line.match(/at\s+(?:.*\()?(.+):(\d+):(\d+)\)?/);
if (rawMatch) {
const fullPath = rawMatch[1];
const lineNumber = rawMatch[2];
const columnNumber = rawMatch[3];
const pathMatch = parseStackTracePath(line);
if (pathMatch) {
const { fullPath, lineNumber, columnNumber } = pathMatch;
const isInternal =
fullPath.includes("atums.echo") || fullPath.includes("@atums/echo");
if (
fullPath.includes("/logger/") ||
fullPath.includes("/src/index.ts") ||
fullPath.includes("/src/lib/")
) {
continue;
}
if (isInternal) continue;
return {
id: id,
id,
fileName: basename(fullPath),
line: lineNumber,
column: columnNumber,
......@@ -107,6 +102,59 @@ function getCallerInfo(config: Required<LoggerConfig>): {
return fallback;
}
function parseStackTracePath(line: string): {
fullPath: string;
lineNumber: string;
columnNumber: string;
} | null {
// remove "at " prefix and trim
const cleanLine = line.replace(/^\s*at\s+/, "").trim();
let pathPart: string;
// extract path from parentheses if present
const parenMatch = cleanLine.match(/\(([^)]+)\)$/);
if (parenMatch) {
pathPart = parenMatch[1];
} else {
pathPart = cleanLine;
}
let match: RegExpMatchArray | null = null;
if (process.platform === "win32") {
// windows-specific parsing
// handle drive letters (C:) vs line numbers (:10:5)
match = pathPart.match(
/^((?:[a-zA-Z]:|\\\\[^\\]+\\[^\\]+|[^:]+)):(\d+):(\d+)$/,
);
if (!match) {
// try alternative windows format with forward slashes
match = pathPart.match(/^([a-zA-Z]:[^:]+):(\d+):(\d+)$/);
}
if (!match) {
// try UNC path format
match = pathPart.match(/^(\\\\[^:]+):(\d+):(\d+)$/);
}
} else {
// unix-like systems (Linux, macOS)
match = pathPart.match(/^([^:]+):(\d+):(\d+)$/);
}
if (match) {
const [_, fullPath, lineNumber, columnNumber] = match;
return {
fullPath: fullPath.trim(),
lineNumber,
columnNumber,
};
}
return null;
}
function generateShortId(length = 8): string {
const chars =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
......@@ -120,53 +168,212 @@ function generateShortId(length = 8): string {
return id;
}
function replaceColorTokens(
input: string,
level: LogLevel,
function formatData(data: unknown, config: Required<LoggerConfig>): string {
return config.prettyPrint && typeof data === "object" && data !== null
? 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>,
level?: LogLevel,
tag?: string,
): string {
return input
.replace(/{color:(\w+)}/g, (_, colorKey) => {
if (!config.consoleColor) return "";
if (colorKey === "levelColor") {
if (colorKey === "levelColor" && level) {
const colorForLevel =
config.levelColor?.[level] ?? defaultLevelColor[level];
return ansiColors[colorForLevel ?? ""] ?? "";
}
if (colorKey === "tagColor" && tag) {
const normalizedTag = tag.toUpperCase();
return ansiColors[config.tagColors?.[normalizedTag] ?? "green"] ?? "";
}
if (colorKey === "contextColor") {
return ansiColors.cyan ?? "";
}
return ansiColors[colorKey] ?? "";
})
.replace(/{reset}/g, config.consoleColor ? ansiColors.reset : "");
}
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" ||
typeof data === "boolean" ||
data === null ||
data === undefined
) {
return data;
}
if (typeof data === "object") {
try {
return JSON.parse(JSON.stringify(data));
} catch (err) {
if (
(err instanceof TypeError && err.message.includes("circular")) ||
(err instanceof Error && err.message.includes("cyclic"))
) {
return createSafeObjectRepresentation(data);
}
return String(data);
}
}
return data;
}
function createSafeObjectRepresentation(
obj: unknown,
seen = new WeakSet(),
): unknown {
if (obj === null || typeof obj !== "object") {
return obj;
}
if (seen.has(obj as object)) {
return "[Circular Reference]";
}
seen.add(obj as object);
if (Array.isArray(obj)) {
return obj.map((item) => createSafeObjectRepresentation(item, seen));
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
try {
result[key] = createSafeObjectRepresentation(value, seen);
} catch {
result[key] = "[Unserializable]";
}
}
return result;
}
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 formatMultipleData(
dataArray: unknown[],
config: Required<LoggerConfig>,
): string {
return dataArray.map((item) => formatData(item, config)).join(" ");
}
function parsePattern(ctx: PatternContext): string {
const { level, data, config } = ctx;
const { id, fileName, line, column, timestamp, prettyTimestamp } =
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 numericLevel: LogLevelValue = logLevelValues[level];
const final = config.pattern
.replace(/{timestamp}/g, timestamp)
.replace(/{pretty-timestamp}/g, prettyTimestamp)
.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, id)
.replace(/{column}/g, column);
const resolvedData: string = Array.isArray(data)
? formatMultipleData(data, config)
: formatData(data, config);
return replaceColorTokens(final, level, config);
const numericLevel: LogLevelValue = logLevelValues[level];
const tokens: PatternTokens = {
timestamp,
prettyTimestamp,
levelName: level.toUpperCase(),
level: String(numericLevel),
fileName,
line,
column,
data: resolvedData,
id,
};
return processPattern(config.pattern, tokens, config, level);
}
export { parsePattern, getCallerInfo, getTimestamp, generateShortId };
export {
parsePattern,
getCallerInfo,
getTimestamp,
generateShortId,
formatData,
getConsoleMethod,
resolveColor,
serializeLogData,
processPattern,
};
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import type { LogLevel, LoggerConfig } from "@types";
import type { LoggerConfig, LogLevel } from "#types";
const logLevelValues = {
trace: 10,
......@@ -10,7 +11,7 @@ const logLevelValues = {
error: 50,
fatal: 60,
silent: 70,
};
} as const;
const defaultLevelColor: Record<LogLevel, keyof typeof ansiColors> = {
trace: "cyan",
......@@ -24,18 +25,97 @@ const defaultLevelColor: Record<LogLevel, keyof typeof ansiColors> = {
const ansiColors: Record<string, string> = {
reset: "\x1b[0m",
// text styles
dim: "\x1b[2m",
bright: "\x1b[1m",
bold: "\x1b[1m",
italic: "\x1b[3m",
underline: "\x1b[4m",
inverse: "\x1b[7m",
hidden: "\x1b[8m",
strikethrough: "\x1b[9m",
// regular colors (30-37)
black: "\x1b[30m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
purple: "\x1b[35m", // alias for magenta
cyan: "\x1b[36m",
white: "\x1b[37m",
// bold colors (1;30-37)
boldBlack: "\x1b[1;30m",
boldRed: "\x1b[1;31m",
boldGreen: "\x1b[1;32m",
boldYellow: "\x1b[1;33m",
boldBlue: "\x1b[1;34m",
boldMagenta: "\x1b[1;35m",
boldPurple: "\x1b[1;35m", // alias for bold magenta
boldCyan: "\x1b[1;36m",
boldWhite: "\x1b[1;37m",
// underline colors (4;30-37)
underlineBlack: "\x1b[4;30m",
underlineRed: "\x1b[4;31m",
underlineGreen: "\x1b[4;32m",
underlineYellow: "\x1b[4;33m",
underlineBlue: "\x1b[4;34m",
underlineMagenta: "\x1b[4;35m",
underlinePurple: "\x1b[4;35m", // alias for underline magenta
underlineCyan: "\x1b[4;36m",
underlineWhite: "\x1b[4;37m",
// background colors (40-47)
bgBlack: "\x1b[40m",
bgRed: "\x1b[41m",
bgGreen: "\x1b[42m",
bgYellow: "\x1b[43m",
bgBlue: "\x1b[44m",
bgMagenta: "\x1b[45m",
bgPurple: "\x1b[45m", // alias for bg magenta
bgCyan: "\x1b[46m",
bgWhite: "\x1b[47m",
// high intensity colors (90-97)
gray: "\x1b[90m",
};
brightBlack: "\x1b[90m", // alias for gray
brightRed: "\x1b[91m",
brightGreen: "\x1b[92m",
brightYellow: "\x1b[93m",
brightBlue: "\x1b[94m",
brightMagenta: "\x1b[95m",
brightPurple: "\x1b[95m", // alias for bright magenta
brightCyan: "\x1b[96m",
brightWhite: "\x1b[97m",
// bold high intensity colors (1;90-97)
boldBrightBlack: "\x1b[1;90m",
boldGray: "\x1b[1;90m", // alias for bold bright black
boldBrightRed: "\x1b[1;91m",
boldBrightGreen: "\x1b[1;92m",
boldBrightYellow: "\x1b[1;93m",
boldBrightBlue: "\x1b[1;94m",
boldBrightMagenta: "\x1b[1;95m",
boldBrightPurple: "\x1b[1;95m", // alias for bold bright magenta
boldBrightCyan: "\x1b[1;96m",
boldBrightWhite: "\x1b[1;97m",
// high intensity background colors (100-107)
bgBrightBlack: "\x1b[100m",
bgGray: "\x1b[100m", // alias for bg bright black
bgBrightRed: "\x1b[101m",
bgBrightGreen: "\x1b[102m",
bgBrightYellow: "\x1b[103m",
bgBrightBlue: "\x1b[104m",
bgBrightMagenta: "\x1b[105m",
bgBrightPurple: "\x1b[105m", // alias for bg bright magenta
bgBrightCyan: "\x1b[106m",
bgBrightWhite: "\x1b[107m",
} as const;
const defaultConfig: Required<LoggerConfig> = {
directory: "logs",
......@@ -43,7 +123,9 @@ const defaultConfig: Required<LoggerConfig> = {
disableFile: false,
rotate: true,
maxFiles: 3,
maxFiles: null,
fileNameFormat: "yyyy-MM-dd",
subDirectory: null,
console: true,
consoleColor: true,
......@@ -65,19 +147,136 @@ const defaultConfig: Required<LoggerConfig> = {
fatal: "red",
},
customColors: {},
tagColors: {},
customPattern:
"{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}",
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.tagColors) {
const validtagColors: Record<string, keyof typeof ansiColors> = {};
for (const [tag, color] of Object.entries(sanitized.tagColors)) {
if (!isValidColor(color)) {
warnings.push(
`Invalid color "${color}" for tag "${tag}" in ${source}, skipping`,
);
continue;
}
validtagColors[tag] = color;
}
sanitized.tagColors = validtagColors;
}
if (warnings.length > 0) {
console.warn(
`[@atums/echo] Configuration warnings:\n ${warnings.join("\n ")}`,
);
}
return sanitized;
}
function loadLoggerConfig(configPath = "logger.json"): LoggerConfig {
try {
const fullPath: string = resolve(process.cwd(), configPath);
const raw: string = readFileSync(fullPath, "utf-8");
return JSON.parse(raw);
} catch {
const parsed = JSON.parse(raw);
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 {};
}
}
......@@ -85,50 +284,101 @@ function loadLoggerConfig(configPath = "logger.json"): LoggerConfig {
function loadEnvConfig(): LoggerConfig {
const config: LoggerConfig = {};
if (process.env.LOG_LEVEL) config.level = process.env.LOG_LEVEL as LogLevel;
if (process.env.LOG_DIRECTORY) config.directory = process.env.LOG_DIRECTORY;
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_FILES)
config.maxFiles = Number.parseInt(process.env.LOG_MAX_FILES, 10);
if (process.env.LOG_CONSOLE)
config.console = process.env.LOG_CONSOLE === "true";
if (process.env.LOG_CONSOLE_COLOR)
config.consoleColor = process.env.LOG_CONSOLE_COLOR === "true";
if (process.env.LOG_DATE_FORMAT)
if (process.env.LOG_LEVEL && isValidLogLevel(process.env.LOG_LEVEL)) {
config.level = process.env.LOG_LEVEL;
}
if (process.env.LOG_DIRECTORY) {
config.directory = process.env.LOG_DIRECTORY;
}
config.disableFile = parseBooleanEnv(process.env.LOG_DISABLE_FILE);
config.rotate = parseBooleanEnv(process.env.LOG_ROTATE);
config.console = parseBooleanEnv(process.env.LOG_CONSOLE);
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_SUB_DIRECTORY) {
config.subDirectory = process.env.LOG_SUB_DIRECTORY;
}
if (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_PRETTY_PRINT)
config.prettyPrint = process.env.LOG_PRETTY_PRINT === "true";
}
if (process.env.LOG_TIMEZONE) {
config.timezone = process.env.LOG_TIMEZONE;
}
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) {
const colors = process.env.LOG_LEVEL_COLOR.split(",");
for (const color of colors) {
const [level, colorName] = color.split(":");
if (logLevelValues[level as LogLevel] !== undefined) {
config.levelColor = {
...config.levelColor,
[level as LogLevel]: colorName as keyof typeof ansiColors,
};
const levelColor: Partial<Record<LogLevel, keyof typeof ansiColors>> = {};
for (const colorPair of colors) {
const [level, colorName] = colorPair.split(":");
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) {
const colors = process.env.LOG_CUSTOM_COLORS.split(",");
for (const color of colors) {
const [tag, colorName] = color.split(":");
config.customColors = {
...config.customColors,
[tag]: colorName as keyof typeof ansiColors,
};
if (process.env.LOG_TAG_COLORS) {
const colors = process.env.LOG_TAG_COLORS.split(",");
const tagColors: Record<string, keyof typeof ansiColors> = {};
for (const colorPair of colors) {
const [tag, colorName] = colorPair.split(":");
if (tag && colorName && isValidColor(colorName)) {
tagColors[tag] = colorName;
} else {
console.warn(`[@atums/echo] Invalid custom color pair: ${colorPair}`);
}
}
if (Object.keys(tagColors).length > 0) {
config.tagColors = tagColors;
}
}
const sanitizedConfig = validateAndSanitizeConfig(
config,
"environment variables",
);
return config;
return Object.fromEntries(
Object.entries(sanitizedConfig).filter(([_, value]) => value !== undefined),
) as LoggerConfig;
}
export {
......@@ -138,4 +388,7 @@ export {
loadEnvConfig,
logLevelValues,
ansiColors,
validateAndSanitizeConfig,
isValidLogLevel,
isValidColor,
};
import {
type WriteStream,
createWriteStream,
existsSync,
mkdirSync,
readdirSync,
unlinkSync,
type WriteStream,
} from "node:fs";
import { join } from "node:path";
import type { LogLevel, LoggerConfig } from "@types";
import { format } from "date-fns-tz";
import { serializeLogData } from "#lib/char";
import type { LoggerConfig, LogLevel } from "#types";
class FileLogger {
private stream: WriteStream | null = null;
private filePath = "";
private date = "";
private fileNameFormat = "yyyy-MM-dd";
private logDirectory: string;
constructor(private readonly config: Required<LoggerConfig>) {
if (!existsSync(this.config.directory)) {
mkdirSync(this.config.directory, { recursive: true });
this.logDirectory = this.config.subDirectory
? join(this.config.directory, this.config.subDirectory)
: this.config.directory;
mkdirSync(this.logDirectory, { 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 {
return join(this.config.directory, `${dateStr}.jsonl`);
const fileName = `${dateStr}.jsonl`;
return join(this.logDirectory, fileName);
}
private resetStream(path: string): void {
......@@ -31,13 +50,32 @@ class FileLogger {
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 {
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.");
}
const files = readdirSync(this.config.directory)
.filter((file) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(file))
const fileRegex = this.generateFileRegex();
const files = readdirSync(this.logDirectory)
.filter((file) => fileRegex.test(file))
.sort();
const excess = files.slice(
......@@ -47,11 +85,18 @@ class FileLogger {
for (const file of excess) {
try {
unlinkSync(join(this.config.directory, file));
unlinkSync(join(this.logDirectory, file));
} catch {}
}
}
private getFilePath(dateStr?: string): string {
if (this.config.rotate && dateStr) {
return this.getLogFilePath(dateStr);
}
return join(this.logDirectory, "log.jsonl");
}
public write(
level: LogLevel | string,
data: unknown,
......@@ -67,27 +112,6 @@ class FileLogger {
if (this.config.disableFile) return;
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({
timestamp: new Date(meta.timestamp).getTime(),
level,
......@@ -95,18 +119,28 @@ class FileLogger {
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,
data: serializeLogData(data),
})}\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 {
this.stream?.write(line);
} catch (err) {
......
......@@ -2,12 +2,10 @@
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
"@types": ["types/index.ts"],
"@types/*": ["types/*"],
"@lib/*": ["src/lib/*"]
"#types": ["types/index.ts"],
"#lib/*": ["src/lib/*"]
},
"typeRoots": ["./types", "./node_modules/@types"],
"typeRoots": ["./node_modules/@types"],
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
......
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: {
resolve: true,
compilerOptions: {
module: "ESNext",
moduleResolution: "bundler",
},
},
outDir: "dist",
minify: true,
splitting: false,
sourcemap: false,
clean: true,
treeshake: true,
target: "es2024",
define: {
"process.env.NODE_ENV": '"production"',
},
outExtension({ format }) {
return {
js: format === "cjs" ? ".cjs" : ".js",
dts: ".d.ts",
};
},
});
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 LoggerConfig = {
......@@ -9,7 +9,9 @@ type LoggerConfig = {
disableFile?: boolean;
rotate?: boolean;
maxFiles?: number;
maxFiles?: number | null;
fileNameFormat?: string;
subDirectory?: string | null;
console?: boolean;
consoleColor?: boolean;
......@@ -23,15 +25,35 @@ type LoggerConfig = {
levelColor?: Partial<Record<LogLevel, keyof typeof ansiColors>>;
customPattern?: string;
customColors?: Record<string, keyof typeof ansiColors>;
tagColors?: Record<string, keyof typeof ansiColors>;
prettyPrint?: boolean;
};
interface PatternContext {
type PatternContext = {
level: LogLevel;
data: unknown;
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,
};