try fix for windows, change how logging works allows much more flexibility
All checks were successful
Code quality checks / biome (push) Successful in 9s
All checks were successful
Code quality checks / biome (push) Successful in 9s
This commit is contained in:
parent
15d4978ac5
commit
f313a8a329
6 changed files with 297 additions and 51 deletions
178
README.md
178
README.md
|
@ -1,24 +1,18 @@
|
|||
# @atums/echo
|
||||
|
||||
A minimal, flexible logger for Node with:
|
||||
|
||||
- Colored console output
|
||||
- Daily `.jsonl` file logging
|
||||
- Configurable output patterns and file naming
|
||||
- Structured logs with caller metadata
|
||||
- Fully typed config with environment/file/constructor override
|
||||
|
||||
---
|
||||
A minimal, flexible logger for Node
|
||||
|
||||
## Features
|
||||
|
||||
- Console and file logging with level-based filtering
|
||||
- Multiple arguments per log call with automatic formatting
|
||||
- Colored output with ANSI formatting
|
||||
- Daily rotated `.jsonl` files with custom naming patterns
|
||||
- Supports runtime configuration merging
|
||||
- Auto-formatted output using custom patterns
|
||||
- Includes caller file, line, and column
|
||||
- Pretty-prints structured objects if enabled
|
||||
- Safe handling of circular references and complex objects
|
||||
- Flushes open file streams on exit
|
||||
- Uses Biome and EditorConfig for formatting and linting
|
||||
|
||||
|
@ -34,19 +28,74 @@ bun add @atums/echo
|
|||
|
||||
## Usage
|
||||
|
||||
### Basic Logging
|
||||
|
||||
```ts
|
||||
import { echo } from "@atums/echo";
|
||||
|
||||
// Single arguments
|
||||
echo.info("App started");
|
||||
echo.debug({ state: "init", ok: true });
|
||||
|
||||
// Multiple arguments - all joined with spaces in console
|
||||
echo.info("User login:", { userId: 123, ip: "192.168.1.1" });
|
||||
echo.warn("Rate limit exceeded:", 429, { endpoint: "/api/users" });
|
||||
echo.error("Database error:", error, { query: "SELECT * FROM users" });
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```ts
|
||||
try {
|
||||
throw new Error("Something failed");
|
||||
} catch (err) {
|
||||
// Single error
|
||||
echo.error(err);
|
||||
|
||||
// Error with context
|
||||
echo.error("Operation failed:", err, { userId: 123, operation: "login" });
|
||||
|
||||
// Multiple context items
|
||||
echo.fatal("Critical error:", err, "System shutting down", { timestamp: Date.now() });
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Data Types
|
||||
|
||||
```ts
|
||||
// Mix any data types
|
||||
echo.info("Processing:", 42, true, { batch: "A1" }, ["item1", "item2"]);
|
||||
echo.debug("State:", "active", { connections: 5 }, null, undefined);
|
||||
echo.warn("Alert:", "High CPU usage:", 95.2, "%", { threshold: 80 });
|
||||
```
|
||||
|
||||
### API Request Logging
|
||||
|
||||
```ts
|
||||
// Custom tagged logs for HTTP requests
|
||||
echo.custom("GET", "/api/users", { status: 200, duration: "15ms" });
|
||||
echo.custom("POST", "/api/auth", { status: 401, error: "Invalid token" });
|
||||
|
||||
// Standard logs with request context
|
||||
echo.info("API Request:", "GET /health", { status: 200, responseTime: "5ms" });
|
||||
echo.error("API Error:", "POST /users", 500, { error: "Database timeout" });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Log Levels
|
||||
|
||||
All log levels support multiple arguments:
|
||||
|
||||
```ts
|
||||
echo.trace("Trace message:", data1, data2);
|
||||
echo.debug("Debug info:", object, array, "string");
|
||||
echo.info("Information:", value1, value2, value3);
|
||||
echo.warn("Warning:", message, errorCode, context);
|
||||
echo.error("Error occurred:", error, additionalData);
|
||||
echo.fatal("Fatal error:", error, "system", "shutdown");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
@ -159,7 +208,7 @@ These tokens are replaced in the log pattern:
|
|||
| `{file-name}` | Source filename |
|
||||
| `{line}` | Line number in source |
|
||||
| `{column}` | Column number in source |
|
||||
| `{data}` | Formatted log data (message/object) |
|
||||
| `{data}` | Formatted log data |
|
||||
| `{id}` | Unique short ID for the log |
|
||||
| `{tag}` | Custom tag used in `echo.custom()` |
|
||||
| `{context}` | Custom context in `echo.custom()` |
|
||||
|
@ -177,6 +226,8 @@ You can log arbitrary tagged messages with `echo.custom(tag, context, message)`:
|
|||
|
||||
```ts
|
||||
echo.custom("GET", "/health", { status: 200 });
|
||||
echo.custom("WEBHOOK", "payment_received", { amount: 99.99, userId: "abc123" });
|
||||
echo.custom("CRON", "daily_backup", { files: 1420, duration: "2m 15s" });
|
||||
```
|
||||
|
||||
The output format is controlled by:
|
||||
|
@ -188,22 +239,39 @@ The output format is controlled by:
|
|||
|
||||
```
|
||||
2025-05-24 16:22:00.123 [GET] (/health) { status: 200 }
|
||||
2025-05-24 16:22:01.456 [WEBHOOK] (payment_received) { amount: 99.99, userId: "abc123" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output Examples
|
||||
|
||||
### Console
|
||||
### Console Output
|
||||
|
||||
**Single argument:**
|
||||
```
|
||||
2025-05-24 16:15:00.000 [INFO] (index.ts:3:6) Server started
|
||||
```
|
||||
|
||||
### File (`logs/2025-05-24.jsonl`)
|
||||
**Multiple arguments:**
|
||||
```
|
||||
2025-05-24 16:15:01.123 [ERROR] (index.ts:8:6) Database error: Error: Connection timeout {
|
||||
query: 'SELECT * FROM users',
|
||||
duration: '5s'
|
||||
}
|
||||
```
|
||||
|
||||
Each line is structured JSON:
|
||||
**Mixed data types:**
|
||||
```
|
||||
2025-05-24 16:15:02.456 [WARN] (index.ts:12:6) Rate limit: 429 exceeded for /api/users {
|
||||
ip: '192.168.1.1',
|
||||
attempts: 15
|
||||
}
|
||||
```
|
||||
|
||||
### File Output (`logs/2025-05-24.jsonl`)
|
||||
|
||||
**Single argument JSON:**
|
||||
```json
|
||||
{
|
||||
"timestamp": 1748115300000,
|
||||
|
@ -212,30 +280,96 @@ Each line is structured JSON:
|
|||
"file": "index.ts",
|
||||
"line": "3",
|
||||
"column": "6",
|
||||
"data": "Server started"
|
||||
"data": ["Server started"]
|
||||
}
|
||||
```
|
||||
|
||||
If an error is logged:
|
||||
|
||||
**Multiple arguments JSON:**
|
||||
```json
|
||||
{
|
||||
"timestamp": 1748115301000,
|
||||
"timestamp": 1748115301123,
|
||||
"level": "error",
|
||||
"id": "qW3eR7tU",
|
||||
"file": "index.ts",
|
||||
"line": "10",
|
||||
"column": "12",
|
||||
"data": {
|
||||
"line": "8",
|
||||
"column": "6",
|
||||
"data": [
|
||||
"Database error:",
|
||||
{
|
||||
"name": "Error",
|
||||
"message": "Something failed",
|
||||
"stack": "Error: Something failed\n at index.ts:10:12"
|
||||
"message": "Connection timeout",
|
||||
"stack": "Error: Connection timeout\n at index.ts:8:6"
|
||||
},
|
||||
{
|
||||
"query": "SELECT * FROM users",
|
||||
"duration": "5s"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Custom log JSON:**
|
||||
```json
|
||||
{
|
||||
"timestamp": 1748115302456,
|
||||
"level": "GET",
|
||||
"id": "mN8oP2qR",
|
||||
"file": "index.ts",
|
||||
"line": "15",
|
||||
"column": "6",
|
||||
"data": {
|
||||
"context": "/health",
|
||||
"data": { "status": 200 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Circular Reference Handling
|
||||
|
||||
The logger safely handles circular references without crashing:
|
||||
|
||||
```ts
|
||||
const obj = { name: "test" };
|
||||
obj.self = obj; // Creates circular reference
|
||||
|
||||
echo.info("Circular object:", obj); // Works safely
|
||||
// Console: Shows <ref *1> { name: 'test', self: [Circular *1] }
|
||||
// File: Stores { "name": "test", "self": "[Circular Reference]" }
|
||||
```
|
||||
|
||||
### Error Object Serialization
|
||||
|
||||
Error objects are automatically converted to structured data:
|
||||
|
||||
```ts
|
||||
const error = new Error("Something failed");
|
||||
echo.error("Operation failed:", error, { userId: 123 });
|
||||
|
||||
// File output includes:
|
||||
// {
|
||||
// "name": "Error",
|
||||
// "message": "Something failed",
|
||||
// "stack": "Error: Something failed\n at ..."
|
||||
// }
|
||||
```
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
The logger handles rapid logging efficiently:
|
||||
|
||||
```ts
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
echo.debug("Processing item:", i, { batch: "A1", progress: i/1000 });
|
||||
}
|
||||
// All logs are processed without blocking
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
This project uses:
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
"license": "BSD-3-Clause",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/bun": "^1.2.13",
|
||||
"@types/bun": "^1.2.15",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
|
|
31
src/index.ts
31
src/index.ts
|
@ -77,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]
|
||||
|
@ -85,39 +85,38 @@ 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[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 {
|
||||
|
|
131
src/lib/char.ts
131
src/lib/char.ts
|
@ -1,8 +1,8 @@
|
|||
import { basename } from "node:path";
|
||||
import { format, inspect } from "node:util";
|
||||
import { ansiColors, defaultLevelColor, logLevelValues } from "@lib/config";
|
||||
import { format as formatDate } from "date-fns-tz";
|
||||
|
||||
import { ansiColors, defaultLevelColor, logLevelValues } from "@lib/config";
|
||||
import type {
|
||||
LogLevel,
|
||||
LogLevelValue,
|
||||
|
@ -59,6 +59,7 @@ function getCallerInfo(config: Required<LoggerConfig>): {
|
|||
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+)\)?/,
|
||||
);
|
||||
|
@ -79,16 +80,16 @@ function getCallerInfo(config: Required<LoggerConfig>): {
|
|||
};
|
||||
}
|
||||
|
||||
const rawMatch = line.match(/at\s+(?:.*\()?(.+):(\d+):(\d+)\)?/);
|
||||
if (rawMatch) {
|
||||
const [_, fullPath, lineNumber, columnNumber] = rawMatch;
|
||||
const pathMatch = parseStackTracePath(line);
|
||||
if (pathMatch) {
|
||||
const { fullPath, lineNumber, columnNumber } = pathMatch;
|
||||
const isInternal =
|
||||
fullPath.includes("atums.echo") || fullPath.includes("@atums/echo");
|
||||
|
||||
if (isInternal) continue;
|
||||
|
||||
return {
|
||||
id: id,
|
||||
id,
|
||||
fileName: basename(fullPath),
|
||||
line: lineNumber,
|
||||
column: columnNumber,
|
||||
|
@ -101,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";
|
||||
|
@ -166,13 +220,63 @@ function serializeLogData(data: unknown): unknown {
|
|||
};
|
||||
}
|
||||
|
||||
if (typeof data === "string" || typeof data === "number") {
|
||||
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,
|
||||
|
@ -231,15 +335,23 @@ function processPattern(
|
|||
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 = formatData(data, config);
|
||||
const numericLevel: LogLevelValue = logLevelValues[level];
|
||||
const resolvedData: string = Array.isArray(data)
|
||||
? formatMultipleData(data, config)
|
||||
: formatData(data, config);
|
||||
|
||||
const numericLevel: LogLevelValue = logLevelValues[level];
|
||||
const tokens: PatternTokens = {
|
||||
timestamp,
|
||||
prettyTimestamp,
|
||||
|
@ -251,7 +363,6 @@ function parsePattern(ctx: PatternContext): string {
|
|||
data: resolvedData,
|
||||
id,
|
||||
};
|
||||
|
||||
return processPattern(config.pattern, tokens, config, level);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { LogLevel, LoggerConfig } from "@types";
|
||||
|
||||
const logLevelValues = {
|
||||
|
|
|
@ -8,9 +8,10 @@ import {
|
|||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { serializeLogData } from "@lib/char";
|
||||
import type { LogLevel, LoggerConfig } from "@types";
|
||||
import { format } from "date-fns-tz";
|
||||
|
||||
import type { LogLevel, LoggerConfig } from "@types";
|
||||
|
||||
class FileLogger {
|
||||
private stream: WriteStream | null = null;
|
||||
private filePath = "";
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue