@bikiran/logger
v1.0.0
Published
Structured, leveled logging with child loggers, pluggable formatters, and transports. Zero dependencies.
Readme
@bikiran/logger
Structured, leveled logging for Node.js with child loggers, pluggable formatters, and transports.
Zero dependencies. TypeScript-first. Fully portable.
Table of Contents
- Installation
- Quick Start
- Concept & Design
- API Reference
- Log Levels
- Formatters
- Transports
- Error Handling
- Environment Variables
- Usage Patterns
- Testing
- TypeScript
- Architecture
- Contributing
- License
Installation
# npm
npm install @bikiran/logger
# yarn
yarn add @bikiran/logger
# pnpm
pnpm add @bikiran/loggerQuick Start
import { logger } from "@bikiran/logger";
// Simple messages
logger.info("Server started", { port: 3000 });
logger.error("Connection failed", { error: new Error("timeout") });
// Scoped child logger
const wsLogger = logger.child({ module: "websocket" });
wsLogger.info("Client connected", { clientIp: "10.0.0.1" });Output (JSON, default):
{"timestamp":"2026-01-15T10:30:00.000Z","level":"info","message":"Server started","port":3000}
{"timestamp":"2026-01-15T10:30:00.123Z","level":"error","message":"Connection failed","error":{"message":"timeout","name":"Error","stack":"..."}}
{"timestamp":"2026-01-15T10:30:00.456Z","level":"info","message":"Client connected","module":"websocket","clientIp":"10.0.0.1"}Concept & Design
Problem
Raw console.log calls produce unstructured output that is:
- Hard to parse in log aggregation systems (ELK, Loki, CloudWatch)
- Missing timestamps, severity levels, and context
- Scattered across the codebase with no consistency
Solution
This package provides a single, structured logging interface built around five principles:
Structured Output — Every log is a JSON line with
timestamp,level,message, and metadata. Machine-parseable, grep-friendly.Hierarchical Context — Child loggers inherit parent metadata and add their own scope (module, request ID, client IP). No manual context threading.
Separation of Concerns — What to log (Logger) is separated from how to format (Formatter) and where to send (Transport).
Performance by Default — Level checks happen before formatting. If a log is suppressed, zero string work is done.
Zero Dependencies — No
winston,pino, orbunyan. Just TypeScript targeting Node.js built-ins. Install and go.
Data Flow
logger.info("msg", { key: "val" })
│
▼
┌─────────────┐
│ Level Check │──── suppressed? → return (no work done)
└──────┬──────┘
│ passes
▼
┌─────────────┐
│ Merge Meta │ base meta + parent meta + call-site meta
└──────┬──────┘
│
▼
┌─────────────┐
│ Serialize │ Error objects → { message, name, stack, ...props }
│ Errors │
└──────┬──────┘
│
▼
┌─────────────┐
│ Formatter │ LogEntry → string (JSON or pretty)
└──────┬──────┘
│
▼
┌─────────────┐
│ Transport │ string → destination (console, file, network)
└─────────────┘API Reference
Default Logger
import { logger } from "@bikiran/logger";A pre-configured singleton that reads configuration from environment variables. This is the recommended way to use the logger — most code only needs this import.
createLogger(options?)
import { createLogger, prettyFormatter } from "@bikiran/logger";
const customLogger = createLogger({
level: "debug",
formatter: prettyFormatter,
meta: { service: "my-api", version: "2.1.0" },
});Options:
| Property | Type | Default | Description |
| ----------- | ------------------------- | -------------------------------- | --------------------------------- |
| level | LogLevel | LOG_LEVEL env or "info" | Minimum severity to emit |
| formatter | LogFormatter | jsonFormatter | Converts LogEntry to string |
| transport | LogTransport | consoleTransport | Delivers formatted string |
| meta | Record<string, unknown> | {} | Base metadata for every log entry |
| timestamp | () => string | () => new Date().toISOString() | Timestamp generator |
Log Methods
All log methods share the same signature:
logger.debug(message: string, meta?: Record<string, unknown>): void
logger.info(message: string, meta?: Record<string, unknown>): void
logger.warn(message: string, meta?: Record<string, unknown>): void
logger.error(message: string, meta?: Record<string, unknown>): void
logger.fatal(message: string, meta?: Record<string, unknown>): voidExamples:
// No metadata
logger.info("Server started");
// With metadata
logger.info("Request handled", { method: "GET", path: "/api", durationMs: 42 });
// With error
logger.error("DB query failed", {
error: new Error("connection refused"),
query: "SELECT * FROM users",
});Child Loggers
Child loggers inherit the parent's configuration (level, formatter, transport) and merge additional base metadata:
const appLogger = createLogger({ meta: { service: "my-app" } });
const wsLogger = appLogger.child({ module: "websocket" });
const dbLogger = appLogger.child({ module: "database" });
wsLogger.info("Client connected", { clientIp: "10.0.0.1" });
// → { service: "my-app", module: "websocket", clientIp: "10.0.0.1", ... }
dbLogger.error("Query failed", { table: "users" });
// → { service: "my-app", module: "database", table: "users", ... }Child loggers can be nested (grandchild, etc.):
const repoLogger = dbLogger.child({ repository: "conversations" });
repoLogger.info("Row inserted", { sessionId: "abc-123" });
// → { service: "my-app", module: "database", repository: "conversations", sessionId: "abc-123" }Runtime Level Control
logger.getLevel(); // → "info"
logger.setLevel("debug"); // Now debug logs are visible
logger.setLevel("silent"); // Suppress all outputLog Levels
| Level | Priority | When to use |
| -------- | -------- | ---------------------------------------------------------- |
| debug | 0 | Detailed diagnostic information for troubleshooting |
| info | 1 | Normal operational events (startup, connections, requests) |
| warn | 2 | Unexpected but handled situations (rate limit, retry) |
| error | 3 | Failures requiring attention (DB errors, API failures) |
| fatal | 4 | Unrecoverable errors — process should exit after logging |
| silent | 5 | Configuration-only: disables all output |
A log is emitted when its level priority is ≥ the configured minimum level.
Formatters
JSON Formatter (default)
Single-line JSON — optimized for log aggregation (ELK, Loki, CloudWatch).
import { jsonFormatter } from "@bikiran/logger";
// Output:
// {"timestamp":"2026-01-15T10:30:00.000Z","level":"info","message":"Started","port":3000}Field order: timestamp → level → message → ...metadata
Pretty Formatter
Colorized, human-readable — designed for local development terminals.
import { createLogger, prettyFormatter } from "@bikiran/logger";
const devLogger = createLogger({ formatter: prettyFormatter });
devLogger.info("Request", { method: "GET", path: "/api" });
// Output:
// 10:30:00.000 INFO Request method=GET path=/apiFeatures:
- ANSI colors per severity level
- Time-only display (HH:MM:SS.mmm)
- Key=value metadata pairs
- Error stacks on separate indented lines
Custom Formatters
Implement the LogFormatter type:
import type { LogFormatter } from "@bikiran/logger";
const csvFormatter: LogFormatter = (entry) => {
return `${entry.timestamp},${entry.level},"${entry.message}"`;
};
const logger = createLogger({ formatter: csvFormatter });Or use the field-selection factory:
import { createJsonFormatter } from "@bikiran/logger";
// Only include specific fields
const minimal = createJsonFormatter(["timestamp", "level", "message"]);Transports
Console Transport (default)
Routes logs to the appropriate console method:
| Log Level | Console Method | Stream |
| ---------------- | --------------- | ------ |
| debug | console.debug | stdout |
| info | console.log | stdout |
| warn | console.warn | stderr |
| error, fatal | console.error | stderr |
Noop Transport
Discards all output — useful for testing:
import { createLogger, noopTransport } from "@bikiran/logger";
const silentLogger = createLogger({ transport: noopTransport });Custom Transports
Implement the LogTransport interface:
import type { LogTransport } from "@bikiran/logger";
import fs from "fs";
const fileTransport: LogTransport = {
write(level, output) {
fs.appendFileSync("/var/log/app.log", output + "\n");
},
};Or use the function factory:
import { createFnTransport } from "@bikiran/logger";
const lines: string[] = [];
const arrayTransport = createFnTransport((level, output) => {
lines.push(output);
});Multi-transport — fan out to multiple destinations:
import { createFnTransport, consoleTransport } from "@bikiran/logger";
import type { LogTransport } from "@bikiran/logger";
function createMultiTransport(...transports: LogTransport[]): LogTransport {
return {
write(level, output) {
for (const t of transports) {
t.write(level, output);
}
},
};
}
const multi = createMultiTransport(consoleTransport, fileTransport);
const logger = createLogger({ transport: multi });Error Handling
Error objects passed in metadata are automatically serialized:
const err = Object.assign(new Error("connection refused"), {
code: "ECONNREFUSED",
port: 5432,
});
logger.error("DB connection failed", { error: err });Output:
{
"timestamp": "...",
"level": "error",
"message": "DB connection failed",
"error": {
"message": "connection refused",
"name": "Error",
"stack": "Error: connection refused\n at ...",
"code": "ECONNREFUSED",
"port": 5432
}
}This works for any Error subclass and preserves all custom enumerable properties.
Environment Variables
The default logger singleton reads these environment variables at startup:
| Variable | Values | Default | Description |
| ------------ | --------------------------------------------------- | ------- | ---------------------------------------------------------------------- |
| LOG_LEVEL | debug, info, warn, error, fatal, silent | info | Minimum severity to emit |
| LOG_FORMAT | json, pretty | json | Output format |
| NODE_ENV | development, production, etc. | — | Auto-selects pretty format in development if LOG_FORMAT is not set |
# Production
LOG_LEVEL=warn node app.js
# Development
NODE_ENV=development node app.js # auto-pretty format
# Explicit
LOG_LEVEL=debug LOG_FORMAT=pretty node app.jsUsage Patterns
Per-Module Scoping
// In each module, create a child logger with the module name
import { logger } from "@bikiran/logger";
const log = logger.child({ module: "websocket" });
export function onConnect(clientIp: string) {
log.info("Client connected", { clientIp });
}Request-Scoped Context
// Create a child logger per request with correlation ID
function handleRequest(req: Request) {
const reqLogger = logger.child({
requestId: req.headers.get("x-request-id"),
method: req.method,
path: req.url,
});
reqLogger.info("Request started");
// ... pass reqLogger to downstream services
reqLogger.info("Request completed", { durationMs: 42 });
}Service Identification
// In the application entry point, configure the root logger with service info
import { createLogger } from "@bikiran/logger";
export const logger = createLogger({
meta: {
service: "my-api",
version: "1.5.0",
env: process.env.NODE_ENV || "development",
},
});
// All logs (including child loggers) will include service, version, envConditional Debug Logging
// Expensive debug output is skipped entirely if level > debug
logger.debug("Full payload dump", {
payload: JSON.parse(rawBody),
});
// For truly expensive operations, guard explicitly:
if (logger.getLevel() === "debug") {
const analysis = performExpensiveAnalysis(data);
logger.debug("Analysis result", { analysis });
}Express / Koa Middleware
import { createLogger, prettyFormatter } from "@bikiran/logger";
const logger = createLogger({
meta: { service: "api-gateway" },
formatter: process.env.NODE_ENV === "development" ? prettyFormatter : undefined,
});
app.use((req, res, next) => {
const start = Date.now();
const reqLog = logger.child({ requestId: req.id, method: req.method, path: req.path });
res.on("finish", () => {
reqLog.info("Request completed", {
status: res.statusCode,
durationMs: Date.now() - start,
});
});
next();
});Testing
Testing Your Code That Uses the Logger
Use noopTransport to suppress output, or createFnTransport to capture logs:
import { createLogger, createFnTransport, noopTransport } from "@bikiran/logger";
import type { LogLevel } from "@bikiran/logger";
// Capture logs for assertions
const captured: { level: LogLevel; output: string }[] = [];
const testLogger = createLogger({
level: "debug",
transport: createFnTransport((level, output) =>
captured.push({ level, output }),
),
timestamp: () => "FIXED",
});
// Inject testLogger into your service
const service = new MyService(testLogger);
service.doWork();
expect(captured).toHaveLength(1);
expect(captured[0].level).toBe("info");Running the Package Tests
# Run tests
npm test
# Watch mode
npm run test:watch
# Coverage
npm run test:coverageTypeScript
Full TypeScript support with exported types:
import type {
LogLevel,
LogEntry,
LogFormatter,
LogTransport,
LoggerOptions,
ILogger,
SerializedError,
} from "@bikiran/logger";
// Use ILogger for dependency injection
class UserService {
constructor(private readonly logger: ILogger) {}
createUser(name: string) {
this.logger.info("Creating user", { name });
}
}Exported Types
| Type | Description |
| ----------------- | ------------------------------------------------------------ |
| LogLevel | "debug" \| "info" \| "warn" \| "error" \| "fatal" \| "silent" |
| LogEntry | Structured log entry: { timestamp, level, message, meta } |
| LogFormatter | Function signature: (entry: LogEntry) => string |
| LogTransport | Interface with write(level, output) method |
| LoggerOptions | Configuration options for createLogger() |
| ILogger | Public interface — use for mocks and dependency injection |
| SerializedError | Shape of serialized Error objects in metadata |
Exported Values
| Export | Description |
| --------------------- | ---------------------------------------------------- |
| logger | Pre-configured default singleton |
| Logger | Logger class (for instanceof checks) |
| createLogger() | Factory function |
| loggerConfig | Resolved environment config ({ level, format }) |
| jsonFormatter | JSON line formatter |
| prettyFormatter | Colorized terminal formatter |
| createJsonFormatter | Factory for custom JSON formatters |
| consoleTransport | Default console transport |
| noopTransport | Silent transport for testing |
| createFnTransport | Factory for function-based transports |
| LOG_LEVEL_PRIORITY | Numeric priority map for level comparisons |
Architecture
src/
├── index.ts # Public API barrel + default singleton
├── logger.service.ts # Logger class + createLogger() factory
├── logger.types.ts # All type definitions (zero runtime code)
├── logger.config.ts # Env-based configuration resolver
├── logger.formatters.ts # JSON and Pretty formatters
├── logger.transports.ts # Console, noop, and factory transports
└── logger.test.ts # Vitest test suite| File | Responsibility | Dependencies |
| ------------------- | ---------------------------------------- | --------------------------------- |
| logger.types | Type definitions, LOG_LEVEL_PRIORITY | None |
| logger.config | Reads LOG_LEVEL, LOG_FORMAT env vars | logger.types |
| logger.formatters | JSON + Pretty string formatters | logger.types |
| logger.transports | Console + noop output writers | logger.types |
| logger.service | Logger class, createLogger() | All of the above |
| index | Singleton creation, barrel re-exports | logger.service, logger.config |
All imports are internal. Zero external dependencies.
Contributing
# Clone
git clone https://github.com/bikirandev/logger.git
cd logger
# Install
npm install
# Dev workflow
npm test # Run tests
npm run lint # Type-check
npm run build # Compile CJS + ESM- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'feat: add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
