@silay/logger
v1.0.1
Published
Production-grade, framework-agnostic structured logging infrastructure for Node.js 20+ with request tracing, contextual logging via AsyncLocalStorage, sensitive data redaction, rotating file transports, audit logging, and first-class Express/MongoDB/Redis
Maintainers
Readme
@silay/logger
Production-grade, framework-agnostic structured logging infrastructure for Node.js 20+.
Built on Node.js built-ins only (AsyncLocalStorage, fs/promises, zlib,
stream/promises) — zero runtime dependencies. Ships as native ES
Modules. See Architecture for why Pino/Winston were not
used internally.
Table of Contents
- Installation
- Quick Start
- Configuration
- Log Levels
- Structured Logging
- Request Context
- Express Integration
- Error Logging
- Sensitive Data Redaction
- Child Loggers
- Audit Logging
- File Logging
- Custom Transports
- Production Configuration
- OpenTelemetry Integration
- Graceful Shutdown
- Testing
- Performance Considerations
- Security Considerations
- API Reference
- Architecture
Installation
npm install @silay/loggerNode.js 20+ required (uses stable AsyncLocalStorage and top-level await
in examples). Express is an optional peer dependency — only needed if you
use requestLogger/errorLogger.
Quick Start
import { createLogger } from "@silay/logger";
const logger = createLogger({
service: "finance-api",
environment: "production",
version: "1.4.2",
});
logger.info("Application started");
logger.info("User authenticated", { userId: "u1", deviceId: "d1" });
logger.error("Transaction creation failed", new Error("insufficient funds"));Configuration
const logger = createLogger({
service: "finance-api",
environment: "production", // development | test | staging | production
version: "1.0.0",
level: "info", // trace|debug|info|http|warn|error|fatal|silent
format: "json", // "json" | "pretty"
transports: ["console", "file"], // or pass Transport instances directly
file: {
directory: "./logs",
maxSize: "50MB",
maxFiles: 14,
retentionDays: 14,
compress: true,
},
redact: ["password", "token", "user.credentials", "request.headers.authorization"],
sampling: { enabled: false },
deduplication: { enabled: false, windowMs: 60_000 },
handleProcessSignals: true, // set false if your app already owns SIGTERM/SIGINT/uncaughtException
});Environment variables (only used to fill in values you didn't set explicitly —
explicit options always win): LOG_LEVEL, LOG_FORMAT, SERVICE_NAME,
NODE_ENV (drives the environment profile), SERVICE_VERSION, LOG_DIR.
Environment profiles (defaults applied before your options/env vars, requirement of sensible per-environment behavior):
| Environment | level | format | transports |
|---|---|---|---|
| development | debug | pretty | console |
| test | silent | json | console |
| staging | info | json | console, file |
| production | info | json | console, file |
Log Levels
trace < debug < info < http < warn < error < fatal, plus silent to disable
everything. Levels are integers internally so the enabled/disabled check on
every call is a cheap comparison, not a string lookup.
logger.trace("Very verbose diagnostic detail");
logger.debug("Database query executed");
logger.info("User authenticated");
logger.http("Request completed"); // typically emitted by requestLogger, not called directly
logger.warn("Redis latency is high");
logger.error("Transaction creation failed", error);
logger.fatal("Database connection lost");Structured Logging
Every log call supports a plain message or a message plus a structured fields object:
logger.info("User authenticated");
logger.info("User authenticated", { userId, deviceId, authenticationMethod });Every entry produced follows one consistent, documented schema:
{
"timestamp": "2026-08-09T16:30:00.123Z",
"level": "info",
"message": "User authenticated",
"service": "finance-api",
"environment": "production",
"version": "1.4.2",
"requestId": "req_abc123",
"traceId": "trace_xyz789",
"userId": "user_123",
"module": "auth",
"action": "login",
"durationMs": 124
}Field precedence on key collision (lowest to highest): service-level context
(createLogger({...}) / .child({...})) < ambient request context
(withContext/setContext) < fields passed directly at the call site. The
most specific, most intentional source always wins.
Request Context
Request-scoped context is propagated with AsyncLocalStorage, so nested
services/repositories don't need requestId/userId/traceId threaded
through every function signature:
await logger.withContext({ requestId, traceId, userId }, async () => {
await controller(); // -> service() -> repository() -> logger.info(...) all inherit context
});
// Or mutate the active scope in place, e.g. once you learn the userId mid-request:
logger.setContext({ userId });Recognized fields: requestId, traceId, spanId, userId, sessionId,
deviceId, service, module, operation — plus any arbitrary extra
fields you choose to set. Concurrent requests never leak context into each
other (see test/context.test.js for concurrency proof).
Express Integration
import { requestLogger, errorLogger } from "@silay/logger";
app.use(requestLogger(logger, { trustProxy: false }));
// ...routes...
app.use(errorLogger(logger));Automatically: generates a requestId (or honors an incoming
x-request-id header only if trustProxy: true — otherwise a
client could inject arbitrary correlation IDs into your logs), reads a
W3C traceparent header if present, records method/route/statusCode/
durationMs/response size/user agent/IP/a configurable allow-list of
headers, logs request completion (http level, or error for 5xx),
logs client-aborted requests, and correlates every nested log call via
AsyncLocalStorage. Request bodies are never logged.
{ "level": "http", "requestId": "req_123", "method": "POST", "route": "/api/v1/transactions", "statusCode": 201, "durationMs": 87 }Error Logging
logger.error("Transaction failed", error); // Error passed directly
logger.error("Transaction failed", { error, operation: "createTransaction" }); // Error inside fieldsErrorSerializer produces:
{
"name": "ValidationError",
"message": "amount must be positive",
"stack": "...",
"code": "...",
"statusCode": 400,
"classification": "validation",
"isOperational": true,
"cause": { "name": "Error", "message": "..." }
}Recognized shapes (via duck-typing, no hard dependency on any of these
libraries): Error, Error.cause chains, AggregateError, Axios errors
(isAxiosError), MongoDB (codeName, writeErrors), Mongoose
ValidationError (errors), Node.js system errors (errno, syscall,
path). Stack traces are never sent to HTTP clients — logging and
HTTP error response shaping are deliberately separate (see
examples/express-api.js).
Error classification
import { ValidationError, DatabaseError, NetworkError, SecurityError, InfrastructureError } from "@silay/logger";
throw new ValidationError("amount must be positive"); // classification: "validation", isOperational: true
throw new DatabaseError("write failed", { cause }); // classification: "database", isOperational: falseUse isOperational to distinguish expected/handleable errors (validation,
network blips) from ones that likely indicate a bug or infrastructure
failure and should page someone.
Sensitive Data Redaction
Redaction is recursive, matches key names anywhere in the object graph (case/separator-insensitive), supports precise dotted paths, works through arrays, handles circular references safely, and never mutates the input object (the object you log may be reused elsewhere, e.g. as an HTTP response body).
Redacted by default: password, passwordHash, token, accessToken,
refreshToken, authorization, cookie, set-cookie, apiKey, secret,
clientSecret, privateKey, otp, creditCard, cvv, nationalId, ssn,
pin, and variants.
createLogger({
redact: [
"password", // key-based: matches this key anywhere in the graph
"user.credentials", // path-based: matches only this exact nested location
"request.headers.authorization",
],
});Child Loggers
const transactionLogger = logger.child({ module: "transactions" });
transactionLogger.info("Transaction created"); // -> { module: "transactions", ... }
const createOpLogger = transactionLogger.child({ operation: "create" }); // nests furtherChild loggers are cheap: they share the parent's transports/config by
reference and only carry a small bound-fields object — safe to create per
module, per request, or per function call without leaking memory (see
test/shutdown-and-concurrency.test.js's memory-safety suite).
Audit Logging
import { createAuditLogger } from "@silay/logger";
const auditLogger = createAuditLogger({ service: "finance-api" });
auditLogger.record({
action: "DELETE",
entity: "transaction",
entityId,
actorId,
actorType: "user",
before, after,
ip, userAgent,
metadata,
});Audit events are written to a dedicated logs/audit.log, are still
redacted, and are never subject to level filtering, sampling, or
deduplication — an audit trail with silently dropped entries is a
compliance gap, not a performance win.
File Logging
logs/
├── app.log # everything (or, per your transport config, exactly what you route here)
├── error.log # route separately by attaching a second FileTransport with a level filter
├── http.log
└── audit.log # written automatically by createAuditLoggerFileTransport rotates by size (maxSize), retains at most maxFiles
rotated files (and prunes anything older than retentionDays), gzips
rotated files when compress: true, and uses only asynchronous
fs/stream APIs — never fs.writeFileSync/fs.appendFileSync — so
high log volume never blocks the event loop. To get separate error.log/
http.log files, attach multiple FileTransports with a level:
import { FileTransport, JsonFormatter } from "@silay/logger";
logger.addTransport(new FileTransport({
filename: "./logs/error.log",
formatter: new JsonFormatter(),
level: "error",
}));Custom Transports
import { Transport } from "@silay/logger";
class DatadogTransport extends Transport {
write(entry) {
// ship `entry` (already redacted, already a plain object) to Datadog
}
async flush() { /* await any in-flight network sends */ }
async close() { await this.flush(); /* release sockets */ }
}
logger.addTransport(new DatadogTransport());Production Configuration
const logger = createLogger({
service: "finance-api",
environment: "production",
version: process.env.SERVICE_VERSION,
level: "info",
format: "json",
transports: ["console", "file"],
file: { directory: "/var/log/finance-api", maxSize: "100MB", maxFiles: 30, retentionDays: 30, compress: true },
redact: ["password", "token", "authorization", "ssn"],
deduplication: { enabled: true, windowMs: 60_000 }, // protect against error floods
});See Production Deployment Recommendations below.
OpenTelemetry Integration
The core logger has zero dependency on any observability vendor. Trace correlation works two ways, without coupling:
- If a request carries a W3C
traceparentheader,requestLoggerreads thetraceIdout of it automatically. - If you run an OpenTelemetry SDK in-process, bridge it explicitly at your request boundary:
import { trace } from "@opentelemetry/api";
app.use((req, res, next) => {
const span = trace.getActiveSpan();
if (span) {
const { traceId, spanId } = span.spanContext();
logger.setContext({ traceId, spanId });
}
next();
});For shipping structured logs onward to ELK/Loki/Grafana/Sentry/Datadog,
write a Transport (see Custom Transports) — the
package deliberately does not bundle vendor SDKs.
Graceful Shutdown
await logger.flush(); // wait for all transports to durably write pending entries
await logger.close(); // flush + release file handles/socketsBy default (handleProcessSignals: true), each logger registers handlers
for SIGTERM, SIGINT, uncaughtException, and unhandledRejection:
uncaught exceptions/rejections are logged at fatal before the process
exits with code 1; SIGTERM/SIGINT trigger a flush-then-exit. Listeners are
tracked and removed on close() so creating/closing many loggers (e.g. in
tests) never accumulates listeners. Set handleProcessSignals: false if
your application already owns process-level signal handling and should
call logger.close() itself during its own shutdown sequence.
Testing
npm test # node --test test/
npm run test:coverage # node --test --experimental-test-coverage test/77 tests across levels, structured logging, child loggers, error
serialization (including Mongo/Mongoose/Axios/system-error shapes),
AsyncLocalStorage context propagation and concurrency isolation,
redaction (including circular refs and deep nesting), config validation,
sampling, deduplication, file rotation/compression/retention, Express
middleware, graceful shutdown and process-listener cleanup, and memory
safety. 93% line coverage.
Performance Considerations
- Level gate first. Every log call's first action is an integer
comparison; field merging, redaction, and error serialization only run
if the level is enabled. A disabled
logger.debug(...)costs one comparison. - No synchronous I/O, anywhere.
FileTransportis built entirely onfs.createWriteStream/fs/promises; nothing in the write path can block the event loop. - Redaction runs once per entry, over the already-merged object, not per-field at each call site — cheaper and impossible to "forget."
- Child loggers are O(1) to create — they share the parent's engine (transports, redactor, sampler, deduplicator) by reference and only add a small immutable fields object.
- JSON.stringify happens once, in the formatter, right before the bytes are written — not repeatedly while building up the entry.
- Sampling (§ below) and deduplication exist specifically to cap volume/cost under pathological load without code changes at call sites.
Trade-off made deliberately: FileTransport's size check happens before
each write rather than via a synchronous fs.statSync on every call,
which means a burst of writes can slightly overshoot maxSize before
rotation catches up. This is the right trade for logs, where "roughly
50MB" is fine and "never blocks a request" is not negotiable.
Log Sampling
createLogger({ sampling: { enabled: true, debug: 0.1, info: 1.0 } });Use sampling for: very high-volume, low-value-per-entry logs (debug logs on a hot path, high-frequency health-check http logs) where seeing every entry adds no insight over a representative fraction.
Never use sampling for: error/fatal logs (use deduplication instead — it preserves the fact of every occurrence), audit logs (compliance requires completeness), or any log tied to a specific, individually reconstructable business event (e.g. "transaction created"). Sampling silently drops data.
Deduplication / Rate Limiting
createLogger({ deduplication: { enabled: true, windowMs: 60_000 } });Collapses repeated identical entries (same level + message + module +
code) within the window into one summary with occurrences/firstSeen/
lastSeen, so 50,000 identical connection failures in a minute produce
two log lines (the first occurrence, emitted immediately, plus one
summary), not 50,000.
Security Considerations
- Redaction is allow-nothing-through-by-default for a broad, common set of secret-shaped keys, and is applied centrally so no call site can skip it.
- Redaction never mutates the object you pass in.
- The Express middleware never logs request or response bodies.
- The
x-request-idheader is not trusted by default (trustProxy: false) — untrusted correlation IDs from the public internet could otherwise be used to inject misleading values into your logs. - Mongo/Redis integrations log operation metadata only — never query filters, documents, or command arguments — and are off by default.
- Circular references and pathological depth are handled defensively so a malformed payload can't crash the logging path itself.
- All internal failures (a transport erroring, a config problem)
fall back to a single, explicit emergency path
(
utils/emergencyLog.js) rather than throwing out of the logger and taking down the host application — logging must never be the reason a request fails.
API Reference
createLogger(options?: UserLoggerOptions): Logger
createAuditLogger(options?: UserLoggerOptions): AuditLogger
class Logger {
trace(message: string, fields?: object): void
debug(message: string, fields?: object): void
info(message: string, fields?: object): void
http(message: string, fields?: object): void
warn(message: string, fields?: object): void
error(message: string, fieldsOrError?: object | Error): void
fatal(message: string, fieldsOrError?: object | Error): void
child(fields: object): Logger
setContext(fields: object): boolean // true if an active withContext scope existed
withContext<T>(fields: object, fn: () => T): T
addTransport(transport: Transport): void
flush(): Promise<void>
close(): Promise<void>
}
class AuditLogger {
record(event: { action, entity, entityId?, actorId?, actorType?, before?, after?, ip?, userAgent?, metadata? }): void
}
requestLogger(logger: Logger, options?: { trustProxy?, headers?, skip? }): express.RequestHandler
errorLogger(logger: Logger): express.ErrorRequestHandler
attachMongoLogging(mongoClient, logger, options?: { enabled? }): void
attachRedisLogging(redisClient, logger, options?: { enabled? }): redisClient
class Transport { write(entry): void; flush(): Promise<void>; close(): Promise<void>; }
class ConsoleTransport extends Transport {}
class FileTransport extends Transport { constructor(options: { filename, formatter, maxBytes?, maxFiles?, retentionDays?, compress?, level? }) }
class JsonFormatter { format(entry): string }
class PrettyFormatter { format(entry): string }
RequestContext.generateId(prefix?: string): string
RequestContext.getContext(): object
// Error taxonomy
LoggerError, LoggerConfigError, TransportError
ClassifiedError, ErrorClassification
ValidationError, DatabaseError, NetworkError, SecurityError, InfrastructureErrorEvery exported symbol carries complete JSDoc in source — see the relevant
file under src/ for full parameter/return documentation.
Architecture
See the accompanying design document delivered alongside this package for
the full rationale (folder structure, module boundaries, and the
Pino/Winston/custom comparison). In short: the package is organized so
that core/ (Logger, LogContext, LogEntry, LogLevel) has zero knowledge
of HTTP, Express, files, or any specific database — those live in
transports/, middleware/, and integrations/, which depend on
core/ but never the reverse. context/ splits the generic
AsyncLocalStorage primitive (AsyncContext.js) from the
logging-specific vocabulary built on top of it (RequestContext.js), so
the propagation mechanism itself is reusable. security/ is isolated and
depended upon by core/Logger.js directly, so redaction cannot be
bypassed by any code path that emits a log entry.
Production Deployment Recommendations
- Format: always
jsonin staging/production; ship stdout to your platform's log collector (Docker/Kubernetes log driver, systemd journal) rather than relying solely on the file transport if you're containerized — containers are ephemeral, so treatFileTransportas a local buffer/audit trail, not your primary durability mechanism, unless logs are written to a persistent volume. - Kubernetes / Docker: send
SIGTERMand give the process aterminationGracePeriodSecondslong enough toflush()+close()(a few seconds is typically enough; increase if using slow custom transports). The logger's own SIGTERM handler already does this — do not swallow SIGTERM elsewhere in your app without callinglogger.close()yourself. - File retention: set
retentionDays/maxFilesto match your actual compliance/retention policy, not just disk-space convenience — audit logs in particular often have longer regulatory retention requirements than application logs. - Redaction: treat the
redactlist as part of your security review checklist for every new field you log, not a one-time setup step — add new sensitive field names as your schema grows. - Sampling: only enable for genuinely high-volume, low-value log lines; never for error/fatal/audit.
- Deduplication: enable in production if you've been burned by log floods before; the default window (60s) is a reasonable starting point.
- Multiple services: give every service a distinct
servicename and consistentversion(wireSERVICE_VERSIONfrom your CI build metadata) so aggregated logs are filterable/comparable across a microservice fleet. - Secrets in transports: if you write a custom transport that ships logs over the network (Datadog, Loki, etc.), authenticate that connection the same way you'd authenticate any other outbound service call — the logging pipeline is not exempt from your normal network security posture.
