npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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

  1. Installation
  2. Quick Start
  3. Configuration
  4. Log Levels
  5. Structured Logging
  6. Request Context
  7. Express Integration
  8. Error Logging
  9. Sensitive Data Redaction
  10. Child Loggers
  11. Audit Logging
  12. File Logging
  13. Custom Transports
  14. Production Configuration
  15. OpenTelemetry Integration
  16. Graceful Shutdown
  17. Testing
  18. Performance Considerations
  19. Security Considerations
  20. API Reference
  21. Architecture

Installation

npm install @silay/logger

Node.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 fields

ErrorSerializer 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: false

Use 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 further

Child 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 createAuditLogger

FileTransport 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 traceparent header, requestLogger reads the traceId out 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/sockets

By 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. FileTransport is built entirely on fs.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-id header 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, InfrastructureError

Every 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 json in 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 treat FileTransport as a local buffer/audit trail, not your primary durability mechanism, unless logs are written to a persistent volume.
  • Kubernetes / Docker: send SIGTERM and give the process a terminationGracePeriodSeconds long enough to flush()+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 calling logger.close() yourself.
  • File retention: set retentionDays/maxFiles to 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 redact list 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 service name and consistent version (wire SERVICE_VERSION from 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.