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

sonic-logger

v0.0.1-beta-13

Published

Lightweight, fast, clear structured logging for Node.js & NestJS — auto error classification, request/response tracing with payload logging, an event-loop block watchdog, a resource monitor, and zero-dep OTLP logs/traces/metrics export to Loki, Tempo, Pro

Readme

sonic-logger

npm version npm downloads Known Vulnerabilities License: MIT Node.js version

A structured JSON logger for Node.js and NestJS, with a few extras built in so you don't have to install and wire up 3-4 separate packages: an event-loop freeze detector, a memory/CPU monitor, per-route traffic analytics, request/response logging, and log/trace/metric export to Grafana, Loki, Tempo, Datadog, New Relic, Prometheus, or any OpenTelemetry collector.

✅ 0 known vulnerabilities (npm audit), checked before every publish. One runtime dependency (sonic-boom, the same fast writer Pino uses). A log call never throws and never blocks your app waiting on I/O.

Maintained by Aniruddh Gupta — issues and PRs welcome at github.com/aniruddh-214/super-logger.

Works out of the box, no config needed. Every default (pretty vs. JSON output, colour, timezone, what gets redacted) is auto-detected. You install it, call logger.info(...), and it's already doing the right thing.


Table of contents


Install & quick start

npm i sonic-logger

Requires Node ≥ 18. @nestjs/common is only needed if you use the NestJS adapter.

import { SonicLogger } from 'sonic-logger';

const logger = new SonicLogger({ service: 'orders-api', level: 'info' });

logger.info('server started', { port: 3000 });
logger.warn('cache miss', { key: 'user:42' });
logger.error('payment failed', new Error('card declined'));

In your terminal (dev), you get readable coloured output with the exact file and line that logged it, plus the full error stack:

[2026-09-10 14:49:33.115 IST] │ [INFO] │ [30] │ orders-api/production │ 8842@pod-7 │ server started
   └─ port  3000

[2026-09-10 14:49:33.118 IST] │ [ERROR] │ [50] │ orders-api/production │ 8842@pod-7 │ payment failed
   ├─ sev p1   cat operational
   ├─ at OrdersService#charge (src/orders/service.ts:42:17)
   ├─  err  Error: card declined
   └─ stack
   │  at OrdersService#charge (src/orders/service.ts:42:17)

In production (piped to a file, Docker, Kubernetes), you automatically get one flat JSON object per line instead — this is what log pipelines like Loki or Datadog actually want to ingest:

{"time":"2026-09-10T09:19:33.118Z","level":"ERROR","levelValue":50,"service":"orders-api","msg":"payment failed","source":{"file":"src/orders/service.ts","line":42,"function":"charge","class":"OrdersService"},"err":{"name":"Error","message":"card declined","stack":["OrdersService.charge (src/orders/service.ts:42:17)"]}}

You never configure this switch yourself — it's based on whether the output is going to a real terminal or being piped somewhere. You can force either mode with pretty: true or pretty: false. Either way, if you ever need to read production JSON logs by eye, see Reading production logs.


Log levels

Six levels, low to high severity: trace, debug, info, warn, error, fatal. (log() is just another name for info().) Set level: 'warn' and only warn/error/fatal calls do any work — anything below is skipped before it costs you anything. level: 'silent' turns off all output.

Syslog-style aliases, if your team prefers that vocabulary: emergency/alert/critical/notice/warning. Each one just maps onto one of the six levels above (so your Grafana/Loki dashboards built on level never need to change) while stamping an extra levelName field with the more specific name, in its own colour.

logger.critical('payment gateway unreachable'); // wire level: FATAL, levelName: CRITICAL
logger.notice('feature flag rolled out to 10%'); // wire level: INFO, levelName: NOTICE

Changing the level at runtime

logger.level = 'debug' (or the equivalent logger.setLevel('debug')) changes the threshold live. Fixed in this version: this now propagates to every child created via .child() — even one created before the call — and to every transport that didn't configure its own explicit minLevel. A transport that DOES set its own minLevel keeps that fixed threshold and intentionally never follows the logger's level.

Skip mode: pick exactly what's excluded, instead of a threshold

level is a ladder — it always suppresses everything below one cutoff. Sometimes you want the opposite kind of control: print everything except a couple of specific levels, trace included. Pass skip for that:

const logger = new SonicLogger({ skip: ['error', 'warn'] });

With skip set, the usual level ladder is ignored completely for this logger — a level prints if and only if it's not in the skip array. So skip: ['error', 'warn'] prints trace/debug/info/fatal (yes, even trace, with no level: 'trace' needed) and only suppresses error/warn. skip is optional and fully additive: leave it out and nothing changes — you get today's level ladder behaviour exactly as before. If you somehow pass both, skip wins.

Colours are consistent everywhere. Each level gets a fixed true-colour RGB value (not one of the 16 basic ANSI colours, which look different on every terminal theme) — so error is always the same red whether you're looking at it on macOS Terminal, iTerm, VS Code, or a CI log viewer.

| level | colour | |---|---| | trace | gray | | debug | blue | | info | green | | notice | cyan | | warn / warning | yellow | | error | red | | fatal | white on dark red | | critical | white on burnt orange | | alert | white on purple | | emergency | bright red on near-black |


Errors — automatic classification

Pass an error however feels natural — all three of these work:

logger.error(new Error('boom'));
logger.error('charge failed', err);
logger.error('charge failed', { err, orderId: 'O-1' });

Whichever way you pass it, sonic-logger turns it into a clean structured err object: name, message, a cleaned stack (loader/bundler internals stripped out), code, cause, and any custom properties you attached. It handles circular references and weird error shapes safely — logging an error can never itself crash your app.

Large values are automatically capped, not dumped in full — a huge array becomes [+1800 more items, 2000 total], a huge string becomes "… (N more chars)", and Buffers show as [Binary N bytes]. You always know the size of what was there without it flooding your log line.

What kind of error was it? (category)

sonic-logger looks at the error's type, code, and (if it's an HTTP error) status code, and tags it with a category automatically — useful for building dashboards like "database errors this week" without writing that classification logic yourself.

| category | typical trigger | |---|---| | database | Prisma/Mongo/Sequelize/TypeORM errors, MySQL ER_* codes, Redis errors, deadlocks | | network | connection refused, DNS failure, connection reset | | external_service | a downstream API call failed (Axios, AWS SDK, Azure SDK) | | timeout | a request or operation timed out | | validation | Zod/validation errors, HTTP 400/422 | | auth | JWT errors, HTTP 401/403 | | not_found | HTTP 404, "entity not found" errors | | conflict | duplicate key, HTTP 409 | | rate_limit | HTTP 429, throttling errors | | config | a missing environment variable or bad configuration | | serialization / filesystem | JSON parse errors / file-system errors | | logical | TypeError, ReferenceError, and similar bugs | | operational | a plain thrown Error that didn't match anything more specific | | security | only set when you tag it yourself | | unknown | nothing matched |

You can override the category yourself, or teach it your own project's error shapes (checked before the built-in rules):

logger.error('blocked', { category: 'security', severity: 'p0', ip });

import { registerClassifier } from 'sonic-logger';
registerClassifier((e) => (String(e?.message).includes('quota') ? 'rate_limit' : undefined));

How bad was it? (severity)

p0 (outage/data-loss) · p1 (major) · p2 (partial/minor) · p3 (low). Set automatically from the level (fatal → p0, error → p1, warn → p2) — override with logger.error('...', { severity: 'p0' }) when you know better than the default.


Request context & request logging

Problem this solves: in a busy service, dozens of requests are being handled concurrently. When you look at a log line, how do you know which request it came from? requestContextMiddleware stamps every log made during a request with the same request ID automatically, without you passing it around manually.

import { requestContextMiddleware } from 'sonic-logger/context';

app.use(requestContextMiddleware()); // works the same on Express, Fastify, and Nest

It reuses an inbound x-request-id header if the caller sent one (or a W3C traceparent), otherwise generates a new one, and echoes it back on the response. From then on, every logger.info(...) call made anywhere during that request automatically carries requestId (and traceId/ spanId if present) — you never pass it explicitly.

import { runWithContext, updateContext, generateRequestId } from 'sonic-logger/context';

await runWithContext({ requestId: generateRequestId(), tenantId: 'acme' }, async () => {
  updateContext({ userId: 42 }); // e.g. once you know who the user is, partway through
  logger.info('processing'); // this line automatically includes requestId, tenantId, and userId
});

If you know a particular logger will never need this (a background worker with no request in flight, say), turn it off for a small speed gain: new SonicLogger({ autoContext: false }).

Logging a line per request

The middleware above only attaches IDs — it doesn't log anything by itself. requestLoggerMiddleware is what actually logs one line per completed request, with timing, status, and (opt-in) the request/response bodies:

import { requestLoggerMiddleware } from 'sonic-logger/context';
import { getLogger } from 'sonic-logger';

app.use(requestLoggerMiddleware(getLogger(), {
  logStart: true,   // also log a line the moment the request arrives, not just when it finishes
  slowMs: 2000,      // requests slower than this get logged as a warning, with how much they exceeded by

  log: { query: true }, // what to attach on every request. Off by default except `ip` (cheap, rarely sensitive)

  // turn extra fields on for specific routes only, layered on top of `log`
  perRoute: {
    'POST /orders': { requestBody: true, responseBody: true },
    'POST /login': { requestBody: false }, // never log a login payload, even if `log` turned it on globally
  },
  bodyLimit: 2000, // cap how many characters of a logged body are kept

  // attach your own IDs (customer, tenant, etc.) to every log line for the request
  context: (req) => ({ customerId: req.auth?.customerId, tenantId: req.headers['x-tenant'] }),

  // per-route overrides for everything else this middleware does
  routes: {
    'GET /health': { skip: true },                     // don't log this route at all
    'POST /webhooks/stripe': { trackActivity: false },  // still logged, just excluded from "what's running" attribution
    'POST /reports/export': { slowMs: 10_000 },         // this route is SUPPOSED to be slow
  },
}));

perRoute/routes keys ("METHOD /path" or just "/path") are matched against the incoming URL with its query string stripped first — e.g. 'GET /health' matches an actual request to /health?x=1 — and also support a trailing glob, e.g. 'GET /users/*' matches /users/42. (Fixed in this version: route keys used to be compared against the full raw URL, including the query string, and had no wildcard support at all.)

Each completed request produces one line like this, and an over-budget request is impossible to miss:

{"msg":"request completed (took 3003ms, threshold 3000ms — exceeded by 3ms)","method":"POST","url":"/orders","statusCode":201,"actualMs":3003,"ip":"203.0.113.7"}
  • Logged as info if it finished on time with a 2xx/3xx status, warn on a 4xx or over-budget response, error on a 5xx.
  • ip resolves x-forwarded-for first, then falls back to the raw socket address.
  • Every request is automatically registered so that if the event loop freezes or memory spikes while it's running, the report can name which route was responsible — see Event-loop freeze detection. You don't need to do anything extra for this to work for HTTP requests.

Auto-logging bodies on failure, and surfacing IDs for correlation

Two more opt-in options make a failing request self-explanatory and easy to trace across every log line it touched:

app.use(requestLoggerMiddleware(getLogger(), {
  // on a failing response (status >= 400), force request/response body
  // logging for THAT request even if `log`/`perRoute` left them off —
  // so a failure always comes with enough to diagnose it. An explicit
  // `perRoute: { requestBody: false }` (like the login route above) is
  // never overridden by this, failure or not — that boundary always wins.
  logBodyOnError: true,

  // auto-detect id-shaped fields (`id`, or anything ending in `Id`/`ID` —
  // quoteId, policyId, orderId, ...) already present in the captured
  // request body/params/query, and also promote them as flat top-level
  // fields on the log line, so every log for that id becomes findable
  // with a simple flat-field query instead of digging into nested JSON.
  correlateIds: true,
  // or tune it: correlateIds: { maxFields: 10, sources: ['body', 'params'] }
}));

With correlateIds: true, a request body like { quoteId: 'q-1', amount: 100 } produces a log line with both the nested body AND a flat quoteId: 'q-1' field — amount is left alone since it isn't id-shaped. Only string/number values are promoted (never a nested object), it's capped at maxFields (default 20) per request, and it never overwrites a reserved field (requestId, userId, etc.) that's already on the log line.

Per-request debug trigger (debugWhen)

Sometimes you want full trace/debug detail for ONE request — a specific customer reproducing a bug, a canary request from your own synthetic monitor — without turning it on globally (which would flood every other concurrent request's logs too):

app.use(requestLoggerMiddleware(getLogger(), {
  // your own predicate — entirely your code, your trust boundary. This
  // package does not interpret or trust any specific header itself; if you
  // want header-triggered debug logging, check for it yourself, here.
  debugWhen: (req) => req.headers['x-debug-token'] === process.env.INTERNAL_DEBUG_TOKEN,
  debugLevel: 'trace', // default; lower level while debugWhen matches
}));

When debugWhen(req) returns true, every SonicLogger call made while handling THAT request (inside this middleware's scope, and anything downstream that reads the ambient request context) runs at debugLevel instead of the configured level — for that one request's duration only. No other concurrent request, and no global/shared level, is affected.

Thinning out successful requests (sample)

app.use(requestLoggerMiddleware(getLogger(), {
  sample: { success: 0.1 }, // log ~10% of fast, successful requests
  rng: Math.random,         // optional — inject for deterministic tests
}));

sample.success (0..1, default 1 = log everything, unchanged) thins out the request_end summary line for the uninteresting case only — a 2xx/3xx response that also finished under slowMs. A 4xx/5xx response, or anything over slowMs, always logs, regardless of this ratio. The decision is made after the status code is known (sampling can't apply before you know whether the request failed), and only ever applies to the request_end line — logStart (if enabled) always logs, since it's opt-in/low-volume already and success/failure isn't known yet at that point.


Child loggers & one shared logger

Child loggers — attach fields that should appear on every log line for a scope, without repeating them:

const reqLog = logger.child({ requestId, userId });
reqLog.info('handling'); // both fields included automatically, shares the parent's output stream

One logger for the whole app — instead of passing a logger instance through every file, create it once and fetch the same instance anywhere:

import { getLogger } from 'sonic-logger';

// once, e.g. in main.ts
getLogger({ service: 'orders-api' });

// anywhere else in your codebase — same instance, no dependency injection needed
getLogger().info('processing order');

Reading production logs

Production logs are JSON (one object per line) on purpose — that's what log pipelines like Loki, Datadog, or Vector need. It is not meant to be read directly by a human, and if you've ever run kubectl logs and seen a wall of JSON, that's why it looks messy — you're seeing the wire format, not the intended reading experience.

Pipe it through the bundled CLI to get the same readable tree view you'd see in development, generated live from the JSON:

node app.js | sonic-logger-pretty
kubectl logs -f my-pod | npx sonic-logger-pretty
cat app.log | sonic-logger-pretty --no-color --tz=UTC > readable.txt

Flags: --no-color, --tz=<IANA zone>, --12h (12-hour clock), --skip-invalid (silently drop any line that isn't sonic-logger JSON, instead of passing it through unchanged).

You can also do this programmatically, e.g. to build your own log viewer:

import { createLineRenderer } from 'sonic-logger/view';

const render = createLineRenderer({ color: true });
render(ndjsonLine); // -> the same readable tree string logger.info() prints in dev

Event-loop freeze detection

Problem this solves: Node.js runs your JavaScript on one thread. If something synchronous takes too long (a huge JSON.parse, a bad regex, a tight loop), everything else your server is doing stops until it's done — all other requests queue up. This watches for that and tells you when it happened, for how long, and (as best it can) which request or job was running at the time.

import { watchEventLoop } from 'sonic-logger/metrics';
import { getLogger } from 'sonic-logger';

const watcher = watchEventLoop({
  logger: getLogger(),
  probeIntervalMs: 500, // how often to check
  thresholdMs: 1000,    // a delay longer than this counts as "blocked" and gets logged
  criticalMs: 5000,     // a delay this severe is logged as `critical` instead of `warn`
});

// on shutdown
watcher.stop();
{
  "msg": "event loop blocked for 3120ms (threshold 1000ms) — likely cause: POST /reports/export (just finished, ran 3120ms)",
  "blockedMs": 3120,
  "cause": "POST /reports/export"
}

cause names the most likely responsible request/job — this works automatically for anything going through requestLoggerMiddleware or createMetrics().httpMiddleware(). For your own background jobs, cron tasks, and queue consumers, tell it what's running with beginActivity/ withActivity:

import { beginActivity, endActivity, withActivity } from 'sonic-logger/metrics';

// manual — you're responsible for calling endActivity, even on error
const id = beginActivity('reindex-job', { jobId });
try {
  await reindex();
} finally {
  endActivity(id);
}

// or — ends automatically, even if reindex() throws
await withActivity('reindex-job', () => reindex(), { jobId });

This costs one Map insert and delete per operation — nothing runs unless you actually call it, and it's already wired up automatically for anything going through the built-in HTTP middlewares.

Want the exact file:line, not just the label? Pass captureSource: true — off by default because it costs a stack capture, so it's meant for occasional background jobs, not every single HTTP request:

await withActivity('reindex-job', () => reindex(), { jobId }, { captureSource: true });

Nested operations — if you wrap something inside a request handler (say, an outbound email call) with its own withActivity, and it's the thing that actually blocks, the report names that specific call, not just the outer route:

app.post('/reports/export', async (req, res) => {
  await withActivity('sendMail(creds, content)', () => sendMail(creds, content));
  res.sendStatus(200);
});
event loop blocked for 135ms — likely cause: sendMail(creds, content) (running 150ms) — inside POST /reports/export (running 160ms)

Outside a request (a cron job, a queue worker), scope your own nesting with runWithActivity:

import { beginActivity, endActivity, runWithActivity, withActivity } from 'sonic-logger/metrics';

const jobId = beginActivity('reindex-job', { jobId: id });
await runWithActivity(jobId, async () => {
  await withActivity('reindexBatch', () => reindexBatch(batch)); // links as reindex-job's child
});
endActivity(jobId);

There's also a decorator if you'd rather annotate a method than wrap its body:

import { eventLoopCheck } from 'sonic-logger/metrics';

class ReportService {
  @eventLoopCheck() // options: { label, meta, captureSource: false }
  async export(id: string) { /* ... */ }
}

A history you can summarize, not just scroll through

const watcher = watchEventLoop({ logger: getLogger(), history: true }); // keeps the last 500 events by default

watcher.history.recent(10);   // newest 10 events
watcher.history.summarize();  // { totalBlocks, totalBlockedMs, longestBlockMs, topCauses: [...] }

This history is in memory and resets when your process restarts. To get a summary that survives restarts, write block events to a rotating log file and read them back later:

import { summarizeEventLoopBlocksFromFile } from 'sonic-logger/metrics';

const summary = await summarizeEventLoopBlocksFromFile([
  './logs/event-loop/event-loop-blocks-2026-09-10.log',
]);

Accepting a known-slow (or known-safe) operation

Sometimes you already know an operation can block the loop and can't be fixed right now — but you still want to be alerted if it gets worse than expected. accept is a per-label/route budget, not a blind mute:

watchEventLoop({
  logger: getLogger(),
  accept: {
    'pdf-render': 3000,        // suppressed only while under 3000ms — slower than that still alerts
    'GET /reports/*': 5000,    // glob-matched the same way route keys are elsewhere
    'known-batch-job': false,  // never alert on this one, regardless of duration
  },
});

The same thing is available per-call-site, which always wins over the global map for that activity: withActivity(label, fn, meta, { eventLoop: { acceptMs: 3000 } }) (or { eventLoop: false }). For nested activities, the innermost explicit setting applies — an accepted child inside a non-accepted parent doesn't hide the parent's own attribution; causeChain always reports the full, real chain of what was running, unaffected by any accept setting.

By default an accepted block is still counted in history/onBlocked, just not logged — set recordAccepted: false to make it fully invisible instead.

Why this is cheap enough to run always-on in production: it uses Node's own built-in perf_hooks.monitorEventLoopDelay histogram (reading it is essentially free — Node is already maintaining it), plus one setInterval timer that's .unref()'d so it never keeps your process alive by itself, checking every probeIntervalMs. There's no profiler and no per-request sampling loop.


Resource monitor

Problem this solves: memory or CPU creeping up over time is one of the hardest things to catch before it takes your app down. This checks on an interval and warns you before things get critical, with the exact numbers and (if it can tell) which request or job was running.

import { startResourceMonitor } from 'sonic-logger/metrics';
import { getLogger } from 'sonic-logger';

startResourceMonitor({
  logger: getLogger(),
  intervalSec: 30,       // how often to check
  heapUsedWarnMB: 512,
  rssWarnMB: 1024,
  cpuWarnPercent: 90,
  cooldownSec: 300,       // don't repeat the same ongoing warning more often than this (default: 5 min)
});
{
  "msg": "resource threshold crossed: heap used 540MB (threshold 512MB) — likely responsible: POST /reports/export (running 2400ms)",
  "process": { "heapUsedMB": 540, "rssMB": 701 },
  "system": { "totalMB": 16384, "usedPercent": 79.1 },
  "container": { "limitMB": 1024, "usedPercent": 68.5 },
  "responsible": [{ "label": "POST /reports/export", "elapsedMs": 2400 }],
  "breachingForSec": 90
}

Three different memory numbers, because they answer different questions:

  • process — just this process: its own heap and total memory use (rss).
  • system — the whole machine's RAM, shared with anything else running on it. Inside a container this is the host's memory, which can look alarming for reasons entirely outside your app's control.
  • container — how much RAM is actually allocated to your process (its Docker/Kubernetes limit). This is the number an out-of-memory kill is actually measured against, and it's undefined if you're not in a memory-limited container. Threshold checks use this when it's available, falling back to system when it isn't.

It doesn't spam you. Once it warns about an ongoing breach, it stays quiet about that same breach for cooldownSec (default 300s = 5 minutes) even if every check still crosses the threshold — the field breachingForSec tells you how long it's been going on. The moment it recovers, the next breach alerts immediately (it's treated as a new episode). Set logSnapshots: true if you want a quiet info line on every check regardless, so you can confirm it's still actively watching.

Each check costs one process.memoryUsage() + process.cpuUsage() + os.totalmem/freemem() read — no sampling loop, no forced garbage collection.

Disabling, cooling down, and snoozing individual metrics

Any threshold accepts false to disable it entirely — for a job you know legitimately runs heap-heavy:

startResourceMonitor({ logger: getLogger(), heapUsedWarnMB: false, rssWarnMB: 1024 });

cooldownSec also accepts a per-metric object instead of one shared value:

startResourceMonitor({ logger: getLogger(), cooldownSec: { heap: 60, cpu: 600 } });

startupGraceSec suppresses resource-threshold alerts for N seconds after the monitor starts — the common "warming up caches/connections at boot" case. And at runtime, monitor.snooze(metric?, ms) silences one metric (or everything, if you omit it) for a window you control:

const resource = startResourceMonitor({ logger: getLogger(), startupGraceSec: 30 });
resource.snooze('heap', 10 * 60_000); // about to run a known-heavy batch job

Same recordAccepted split as watchEventLoop (default true): a snoozed/grace-period breach still shows up in history, just without firing the log line or onBreach.

A history of what happened, not just point-in-time numbers

const resource = startResourceMonitor({ logger: getLogger(), heapUsedWarnMB: 512 });

resource.history.summarize();
// -> { totalEpisodes: 4, longestEpisodeMs: 90_000, peak: { heapUsedMB: 812, rssMB: 940 }, ... }

resource.history.recent(); // most recent breach episode first

Turned on by default, keeping the last 200 episodes (pass history: false to disable, or a number to change the size). Each entry is a whole breach episode — when it started, the worst value seen, when it recovered — not raw per-tick numbers you'd have to piece together yourself.

Leftover handles after a request finishes

Turned on by default alongside the resource monitor. If a request or job finishes but leaves a timer, socket, or file handle open (handleGraceSec, default 2 seconds, after it ends), you get a warning naming what's still open and where it was created:

WARN  open handles still active after "POST /clone" finished — close them in cleanup
  3 async resource(s) created during "POST /clone" were still alive 2000ms after it ended
    1. 2 TCP/TLS socket(s) still open — socket.destroy() or release the pooled connection
    2. 1 timer(s) still running — clearTimeout / clearInterval in cleanup

This is a suggestion, not proof of a leak (a connection pool that intentionally keeps sockets open looks the same) — repeated warnings for the same thing are rate-limited so it doesn't spam. You can also run this standalone, without the full resource monitor:

import { startHandleTracker } from 'sonic-logger/metrics';

startHandleTracker({ logger: getLogger(), graceMs: 2000 });

A lazily-initialized SHARED resource (a DB pool, a Redis client, a keep-alive HTTP agent) that gets created on its first use will be attributed to whichever route happened to trigger that first use — often a route hit early after a pod starts, not an actual per-request leak. Two ways to filter that out:

  • Every leftover_handles report carries occurrence (how many SEPARATE times this label has fired a report, not raw handle count) and recurring (occurrence > 1). A one-off pool warm-up fires once; a genuine leak keeps re-triggering on later requests. By default, a label's FIRST-EVER occurrence isn't logged at all — it's still counted internally, but nothing prints — since a first occurrence is, by construction, indistinguishable from exactly this warm-up noise. Only recurring (a SECOND+ occurrence for the same label) logs, which is the actually-actionable "this keeps happening" signal. Set reportFirstOccurrence: true to see every first occurrence too (useful while actively hunting a suspected leak).
  • startupGraceMs (default: 0, disabled) suppresses leftover-handle reports entirely for this long after the tracker starts — process startup is exactly when lazy singletons/pools are most likely to warm up:
startHandleTracker({ logger: getLogger(), graceMs: 2000, startupGraceMs: 15_000 });

If you already know a particular label/route or resource type is expected to leave handles open, ignore suppresses the report (same recordAccepted split as the other two systems — still counted internally by default):

startHandleTracker({
  logger: getLogger(),
  ignore: { labels: ['known-leaky-job', 'GET /legacy/*'], types: ['Timeout'] },
});

For a cheaper, per-call-site opt-out that skips tracking entirely (rather than tracking then suppressing the report), pass trackHandles: false to withActivity:

await withActivity('batch-job', () => runBatch(), undefined, { trackHandles: false });

A handle whose ENTIRE creation stack is framework/dependency code — no application frame anywhere in it (e.g. RxJS/NestJS's own internal interceptor-chaining machinery, which async_hooks attributes to whatever app frame happened to be active when it was created) — gets a longer, separate grace period, frameworkGraceMs (default: 6000), instead of graceMs before being counted as leftover. This absorbs normal-but-slightly -slow framework teardown without suppressing the report outright: a framework-only handle that genuinely never cleans up is still reported, just with more patience. Any handle with even one application frame in its stack — or no captured stack at all (stack-sample budget exhausted) — always uses the normal, shorter graceMs, so a real app-level leak is still caught promptly:

startHandleTracker({ logger: getLogger(), graceMs: 2000, frameworkGraceMs: 6000 });

If a frame of frameworkGraceMs still isn't enough — e.g. a DB driver's own pool idle-reap timer can legitimately outlive any reasonable grace period — ignore.stackPaths fully excludes a resource whenever ANY frame in its captured creation stack matches (string substring, or RegExp), the same way ignore.labels/ignore.types do. Unlike frameworkGraceMs, which only buys more patience, a stackPaths match is never reported at all. This is applied per-resource, so other leftovers in the same report that don't match still surface normally, and a resource with no captured stack (budget exhausted) can never match — failing toward reporting sooner, consistent with the rest of this file:

startHandleTracker({
  logger: getLogger(),
  ignore: { stackPaths: ['tenant-connection.service.ts', /connection\.factory\.ts/] },
});

"Dangerous mode" — stopping a runaway operation

Off by default, and it never kills your whole process — Node can't safely kill "one request" out of a shared heap, so this library never calls process.exit. What it can do, when dangerous: true and memory gets critical (default: 85% of the heap limit), is:

  1. Log a detailed dump of what's happening and what's likely responsible.
  2. Signal that specific operation to abort (via an AbortSignal).
  3. Call a terminate hook you registered for it (e.g. worker.terminate()).

Useful for a worker-thread-per-heavy-job architecture, where you actually can kill just the one worker:

startResourceMonitor({ logger: getLogger(), heapUsedWarnMB: 512, dangerous: true });

await withActivity(
  'cloneWordings',
  () => cloneOnWorker(worker, currentActivitySignal()),
  undefined,
  { terminate: () => void worker.terminate() },
);

Route analytics

Problem this solves: knowing your busiest routes right now, this hour, or this month — with error rate and response times — without wiring up a separate analytics product.

import { createRouteAnalytics, routeAnalyticsMiddleware } from 'sonic-logger/metrics';

const analytics = createRouteAnalytics(); // create once, apply globally
app.use(routeAnalyticsMiddleware(analytics));

analytics.busiest('hour', 10);                     // top 10 routes by traffic, last 7 days of hourly data
analytics.busiest('hour', 10, 1);                   // top 10 routes in just the last hour
analytics.buckets('GET', '/orders/:id', 'second');  // that one route's last 5 minutes, second by second
analytics.routes();                                 // every route's all-time summary
analytics.routes()
// -> [{ method: 'GET', route: '/orders/:id', count: 48213, errorCount: 113, errorRate: 0.0023, minMs: 4, maxMs: 891, avgMs: 42 }]
  • Three time resolutions by default — second (last 5 minutes, second-by-second — good for "what's happening right now"), hour (last 7 days), day (last 90 days — good for trends). You can replace these with your own list of any width and retention.
  • Real calendar months/years, if you need them — a naive "30-day bucket" quietly drifts away from real months the longer your process stays up. calendarGranularity('month' | 'year', retain) buckets by the actual UTC calendar instead, so analytics.busiest('month', 10) reflects real calendar months:
    import { createRouteAnalytics, calendarGranularity } from 'sonic-logger/metrics';
    
    const analytics = createRouteAnalytics({
      granularities: [
        calendarGranularity('month', 24), // last 24 real calendar months
      ],
    });
  • What counts as an "error" is configurable — by default, any status code ≥ 400: createRouteAnalytics({ isError: (status) => status >= 500 }) to only count 5xx as errors.
  • Memory stays bounded no matter how much traffic flows through — each route/time-resolution combination uses a fixed-size ring buffer, and maxRoutes (default 500) caps how many distinct routes are tracked at all, in case route templates (/orders/:id vs. every literal /orders/123, /orders/124, ...) aren't being collapsed before they reach here.
  • You can fold this into createMetrics() (below) instead of running it standalone: createMetrics({ routeAnalytics: {} }).

Error analytics

Problem this solves: "what are my top 50 unique errors in the last 8 days?" / "what errored most in the last hour?" — without shipping every error to an external tracker just to answer that.

import { createErrorAnalytics } from 'sonic-logger/metrics';

const errors = createErrorAnalytics(); // create once, applied globally

try {
  await chargeCard(order);
} catch (err) {
  errors.record(err, { orderId: order.id }); // call this from your own catch blocks / error middleware
  throw err;
}

errors.top(50, { since: '8d' }); // top 50 unique errors, last 8 days
errors.top(10, { since: '1h' }); // top 10, last hour
errors.snapshot();               // everything tracked, all-time
errors.top(5)
// -> [{ signature: 'Error: card declined', name: 'Error', message: 'card declined',
//      count: 812, firstSeenAt: 1758700000000, lastSeenAt: 1758786400000 }]
  • Deduped by name + message, not by stack. A stack carries line numbers and other dynamic data that would defeat dedup — two occurrences of the same logical failure from different call sites still count as one signature.
  • Same self-serve convention as routeAnalyticsMiddleware — nothing is recorded automatically. You call .record(err) from wherever you already handle the error (a catch block, an Express error middleware, a NestJS exception filter). No global log interception, which would cost CPU for every consumer whether they use this or not.
  • Memory stays bounded no matter how many distinct errors flow through — bucketed the same way route-analytics.ts buckets traffic (hour / day ring buffers by default), and each bucket's distinct-signature Map is capped (maxSignaturesPerBucket, default 500) with LRU eviction, plus an all-time cap (maxSignaturesTotal, default 2000).
  • You can fold this into createMetrics() instead of running it standalone: createMetrics({ errorAnalytics: {} }) — then use metrics.errorAnalytics.record(err).

Ignoring known, accepted-as-noise errors

const errors = createErrorAnalytics({
  ignore: {
    names: ['BenignThirdPartyWarning'],       // exact `error.name` match
    messages: [/^deprecated-lib: /],          // RegExp against `error.message`
  },
});

A .record() call for an error whose name matches ignore.names, or whose message matches any ignore.messages pattern, is a complete no-op — not counted, not retrievable via .top()/.snapshot(). Use this for a specific third-party library's benign warning-as-error so it never drowns out the signal in your top-errors report.


Graceful shutdown flush

Problem this solves: a SIGTERM/SIGINT (container stop, Ctrl+C, deploy rollout) can kill the process while buffered log lines are still sitting in the write stream — this flushes them first.

import { SonicLogger, installGracefulShutdown } from 'sonic-logger';

const logger = new SonicLogger({ service: 'orders-api' });
installGracefulShutdown(logger); // opt-in — nothing changes unless you call this
installGracefulShutdown(logger, {
  signals: ['SIGTERM', 'SIGINT'], // default
  timeoutMs: 5000,                // give flush at most 5s before proceeding anyway
  onShutdown: async () => {
    await db.close(); // your own cleanup, runs after the flush
  },
});
  • Fully opt-in. new SonicLogger() / createLogger() never installs this on its own — an app that already handles SIGTERM itself would otherwise suddenly get a second, unrequested handler.
  • Never hangs the process. The flush races against timeoutMs; if it doesn't finish in time, shutdown proceeds anyway instead of blocking forever on a wedged stream.
  • Idempotent. Calling it twice on the same logger is a no-op — you never end up with duplicate signal listeners.
  • Ends with process.exit(0) (or your exitCode) — the standard Node pattern for a signal handler that does its own cleanup.

Log sampling

Problem this solves: a hot path that logs on every call (a cache hit, a per-item loop) can dominate log volume/cost even at debug — sampling lets you log only 1-in-N of those calls, with the dropped calls costing virtually nothing.

import { SonicLogger, createSampledLogger } from 'sonic-logger';

const logger = new SonicLogger({ service: 'orders-api' });
const hotPath = createSampledLogger(logger, { rate: 0.1 }); // ~1 in 10 calls actually log

function onCacheHit(key: string) {
  hotPath.debug('cache hit', { key }); // ~90% of calls never touch serialization or the write stream
}
  • The sampling decision happens before any work is done — a dropped call never reaches serialization, error classification, source capture, or the write stream, so it's a real saving, not just quieter output.
  • rate is 0..1 — 1 logs everything (unchanged behaviour), 0 logs nothing.
  • Deterministic in tests via an injectable rng (default Math.random): createSampledLogger(logger, { rate: 0.5, rng: () => 0.1 }).
  • .child(bindings) on a sampled logger returns another sampled logger at the same rate, so request-scoped fields still work as expected.
  • Fully additive — new SonicLogger() / createLogger() itself is completely unchanged; this is a separate wrapper you opt into per call site.

Alerting hooks

Problem this solves: the resource monitor and event-loop watchdog already log a warning when something breaches — this lets you also get a structured callback, so you can wire your own Slack/PagerDuty/etc without scraping log text.

import { startResourceMonitor, watchEventLoop } from 'sonic-logger/metrics';

startResourceMonitor({
  logger,
  heapUsedWarnMB: 512,
  onBreach: (breach) => {
    // breach: { breaches, severity, heapUsedMB, rssMB, cpuPercent, memoryConstraintPercent, at, breachingForSec }
    pagerDuty.trigger(`resource breach: ${breach.breaches.join(', ')}`);
  },
});

watchEventLoop({
  logger,
  blockThresholdMs: 1000,
  onBlocked: (report) => {
    // report: { blockedMs, at, thresholdMs, activity }
    slack.post(`event loop blocked ${report.blockedMs}ms`);
  },
});
  • Purely additive and optional — omit onBreach/onBlocked and nothing changes; each is a single if guard, zero cost when unused.
  • Fires alongside, never instead of, the existing log line — you keep your logs and get a structured callback on top.
  • Never breaks the monitor — a throwing callback is caught and ignored; the watchdog keeps running either way.

/metrics endpoint

Bundles the event-loop watchdog, slow-route tracking, process stats, and (optionally) the resource monitor behind one Prometheus/JSON endpoint — useful if you already have Grafana/Prometheus and just want one route to scrape instead of wiring each piece separately.

import { createMetrics } from 'sonic-logger/metrics';

const metrics = createMetrics({
  logger,
  eventLoop: { blockThresholdMs: 1000, criticalMs: 5000 },
  slowRoute: { slowMs: 2000, perRoute: { 'POST /checkout': 5000 } },
  resourceMonitor: { intervalSec: 30, heapUsedWarnMB: 512 }, // opt-in
});

app.use(metrics.httpMiddleware());        // per-request timing, slow-route detection, activity tracking
app.get('/metrics', metrics.handler());   // Prometheus text format, or JSON with ?format=json

Includes Prometheus gauges for CPU %, RSS/heap, GC pauses, event-loop delay percentiles, in-flight request count, and per-route stats.

You can require a token to view this route — process internals aren't something every caller should see for free:

const metrics = createMetrics({
  logger,
  authorize: (req) => req.headers['x-metrics-token'] === process.env.METRICS_TOKEN,
});

A failing or throwing authorize returns 401 with no body — it never crashes the route.


⚠️ Runtime control — code-level config + an admin HTTP route (security is YOUR responsibility)

SECURITY WARNING, read before using any of this. The admin HTTP route below grants runtime control over this process's alerting and logging behavior — it can read AND change resource-monitor thresholds, event-loop acceptance, and the handle-tracker ignore list, over the network. This package provides no authentication of its own. You MUST supply your own authorize callback (API key, signed JWT, HTTP Basic auth, an IP allowlist, mTLS — whatever fits your deployment) before this route can even be constructed. Treat it with at least the same care as the /metrics route's authorize option above — if anything, more, since this one can change behavior, not just expose it. Never mount this route unauthenticated.

Code-level: metrics.configure()

Everything the resource monitor / event-loop watchdog / handle tracker hold as options can be changed at runtime, without a restart — the same live-state mechanism setLevel() uses for the logger:

const metrics = createMetrics({
  logger,
  resourceMonitor: { heapUsedWarnMB: 512, cpuWarnPercent: 90 },
  eventLoop: { accept: { 'GET /reports/*': 5000 } },
  handleTracker: { ignore: { types: ['TCPWRAP'] } },
});

// later, at runtime — e.g. "we know a batch job is about to run heavy":
metrics.configure({
  resourceMonitor: { heapUsedWarnMB: 2048, cooldownSec: 60 },
  eventLoop: { accept: { 'POST /bulk-import': 15_000 } },
  handleTracker: { ignore: { labels: ['bulk-import-*'] } },
});

metrics.getConfig(); // read back the current live config, same shape

Fields omitted from a configure() patch keep their current value — every call is a partial update.

HTTP route (opt-in): createAdminHandler

import { createMetrics, createAdminHandler } from 'sonic-logger/metrics';

const metrics = createMetrics({ logger, resourceMonitor: { heapUsedWarnMB: 512 } });

const admin = createAdminHandler(metrics, {
  // REQUIRED — construction THROWS if this is omitted. No default-allow,
  // no built-in token. This is entirely your own code/trust boundary.
  authorize: (req) => req.headers['x-admin-token'] === process.env.ADMIN_TOKEN,
});

app.use('/admin/metrics', express.json()); // your own body parser — req.body must already be parsed JSON
app.all('/admin/metrics', admin);
  • GET returns the current live config as JSON (same shape as metrics.getConfig()).
  • POST/PATCH applies a partial config change (same shape as metrics.configure()'s patch) and returns the resulting config.
  • The incoming body is validated defensively — an unknown field or a wrong-typed value anywhere in the patch gets the WHOLE request rejected with 400, never applied partially. This is a network-reachable mutation surface; malformed input is never trusted.
  • A request authorize rejects gets 401, before any config is read or changed.
  • Omitting authorize entirely throws synchronously at setup time — createAdminHandler refuses to even exist unauthenticated.

File logging

sonic-logger/file writes a rotating log file to disk — same non-blocking writer the main logger uses, zero extra dependencies.

import { createFileTransport } from 'sonic-logger/file';

// everything, rotated daily, kept for 30 days — the defaults
const allLogs = createFileTransport({ dir: './logs' });

// only critical/p0/p1 events, in their own file, kept for 90 days
const criticalOnly = createFileTransport({
  dir: './logs/critical',
  filename: 'critical',
  retention: { maxAgeDays: 90 },
  filter: (r) => r.severity === 'p0' || r.severity === 'p1',
});

new SonicLogger({ transports: [allLogs, criticalOnly] });

All options:

createFileTransport({
  dir: './logs',
  filename: 'app', // base name, no extension

  rotate: {
    by: 'time',         // 'time' | 'size' | 'both'
    interval: 'daily',  // 'daily' | 'hourly' | a custom interval in ms
    maxSizeMB: 100,      // used when `by` is 'size' or 'both'
  },

  retention: {
    maxAgeDays: 30,             // delete files older than this
    maxTotalSizeMB: undefined,  // cap the whole directory's size — oldest files deleted first
    minFreeDiskMB: undefined,   // start deleting oldest files if free disk drops below this
    checkIntervalSec: 3600,     // how often the retention sweep runs
  },

  filter: undefined,  // (record) => boolean — only write records that match
  format: 'json',      // 'json' (one object per line) | 'pretty' (readable text)
  minLevel: undefined, // only write records at or above this level
});

The file currently being written to is never deleted by a retention sweep, even if it looks old by file timestamp. minFreeDiskMB needs Node ≥ 18.15 — on an older Node it's silently skipped and the other rules still apply.

Reading those files back — "top 10 errors this week"

import { countLogsFromFile, queryLogsFromFile } from 'sonic-logger/file';

const topErrors = await countLogsFromFile(
  ['./logs/critical/critical-2026-09-10.log', './logs/critical/critical-2026-09-11.log'],
  {
    since: Date.now() - 2 * 24 * 60 * 60 * 1000,
    groupBy: (r) => String(r.msg),
    limit: 10,
  },
);
// -> [{ key: 'db write failed', count: 42 }, ...]

queryLogsFromFile returns matching raw entries instead of counts, and stops reading as soon as it has limit results, so it never scans a huge file needlessly. Wiring the result into a Slack message, email, or scheduled cron job is left to your own code — this just gives you the data.


Sending logs, traces & metrics elsewhere (OTLP)

Everything below speaks the OpenTelemetry OTLP/HTTP protocol, which means it works with Loki, Tempo, Datadog, New Relic, Honeycomb, Grafana Cloud, or any OpenTelemetry collector — no vendor-specific SDK bundled in.

Logs:

import { createOtlpTransport } from 'sonic-logger/otlp';

const otlp = createOtlpTransport({
  url: 'http://localhost:4318/v1/logs',
  serviceName: 'orders-api',
  headers: { 'api-key': process.env.OTLP_KEY! },
});

new SonicLogger({ transports: [otlp] });

Every transport (including this one) batches its own queue and never blocks your log call waiting for the network — if it can't keep up, oldest entries are dropped and counted rather than growing memory forever.

Traces:

import { createTracer, createOtlpTraceExporter } from 'sonic-logger/otlp';

const tracer = createTracer({
  serviceName: 'orders-api',
  exporter: createOtlpTraceExporter({ url: 'http://localhost:4318/v1/traces' }),
});

app.use((req, res, next) => {
  tracer.withSpan(`${req.method} ${req.path}`, async (span) => {
    span.setAttribute('http.method', req.method);
    await new Promise((resolve) => { res.on('finish', resolve); next(); });
    span.setAttribute('http.status_code', res.statusCode);
  });
});

// anywhere downstream — automatically nests under whatever span is active
await tracer.withSpan('db.query', async (span) => {
  span.setAttribute('db.statement', sql);
  return db.query(sql);
});

Traces share the same request context as your logs, so traceId/spanId end up on both — you can jump from a log line to its trace in Grafana and back. sampleRate (0 to 1, default 1) lets you export only a fraction of traces in high-traffic production; once a trace is sampled, all its child spans are kept too, so you never get a broken half-trace.

Metrics (process health, on an interval):

import { startOtlpMetrics } from 'sonic-logger/otlp';

const metrics = startOtlpMetrics({
  url: 'http://localhost:4318/v1/metrics',
  serviceName: 'orders-api',
  intervalMs: 15000,
});

// later
metrics.stop();

Pushes CPU %, heap, RSS, GC pauses, event-loop delay percentiles, and in-flight activity count to your APM. Same system vs. container memory split as the resource monitor, for the same reason — check whether container.memory.* is present to know if you're actually containerized.


NestJS integration

// main.ts
import { createNestLogger } from 'sonic-logger/nest';

const app = await NestFactory.create(AppModule, {
  bufferLogs: true,
  logger: createNestLogger({ service: 'orders-api' }),
});

Or wired through dependency injection:

import { LoggerModule, SonicLogger } from 'sonic-logger/nest';

@Module({ imports: [LoggerModule.forRoot({ service: 'orders-api' })] })
export class AppModule {}

@Injectable()
class OrdersService {
  constructor(private readonly log: SonicLogger) {}
}

Nest's own Logger.error(message, stack, context) signature is handled correctly — the trailing context string lands in meta.context instead of being logged as junk.


All configuration options

new SonicLogger({
  service: 'app',              // your service name — shows up as `service` in every log line
  level: 'info',                // trace|debug|info|warn|error|fatal|silent
  env: process.env.NODE_ENV,    // shows up as `env`; 'production' switches to JSON output by default

  // --- how it looks ---
  pretty: undefined,      // force readable output on/off (default: on only when connected to a real terminal)
  color: undefined,        // force colour on/off (default: auto-detected; respects the NO_COLOR standard)
  timezone: undefined,     // e.g. 'Asia/Kolkata', 'UTC' — timezone for the readable clock (JSON output always stays UTC)
  clock: '24h',             // '12h' for AM/PM, '24h' for 24-hour time
  timezoneLabel: undefined, // force a specific abbreviation to show, e.g. 'IST'
  blankLine: true,          // one blank line between readable log entries, for scanability

  // --- what gets captured / hidden ---
  base: {},                 // fields stamped on every single log line
  redact: [],                // extra field names to mask (recursive, any nesting depth), on top of the built-in secret-key list — applied once before fan-out, so stdout AND every transport (file, OTLP, custom) see the same masked data; fixed in this version, transports used to receive the raw, unredacted value
  captureSource: 'warn',     // true | false | a level name — capture exact file:line (see Performance section — this is the one setting with a real cost)
  stackFrames: 0,            // max stack frames kept per error (0 = keep all)
  maxDepth: 8,               // how deep to recurse into nested objects before stopping
  maxStringLength: 8192,     // long strings in metadata get truncated past this
  maxArrayLength: 200,       // long arrays get capped to a length hint past this
  autoContext: true,         // automatically attach requestId/traceId/userId from request context

  // --- where logs go ---
  destination: 1,            // a file descriptor or a file path (directories are created automatically)
  transports: [],             // extra destinations — see File logging / OTLP above
  sync: false,                // write synchronously — only useful for tests or short one-off scripts
  minLength: 4096,             // write-batching size in bytes (larger = fewer, bigger writes)
  onError: undefined,          // called if something internal goes wrong (a log call itself never throws)
  autoShutdown: false,         // also flush pending writes on SIGINT/SIGTERM
});

Flushing pending writes on normal process exit happens automatically — you never need to call logger.flush() yourself for a normal shutdown. autoShutdown: true additionally flushes on SIGINT/SIGTERM (Ctrl+C, kill).

Methods available on every logger: trace, debug, info, log (alias for info), warn, error, fatal, plus the syslog aliases (emergency, alert, critical, notice, warning). Each takes (message, meta?) where message can be a string or an Error. Also: child(bindings), flush(), close(), level (readable/settable), droppedCounts().

Want readable output in production instead of JSON? Either force it on the logger itself, or leave the wire format alone and pipe through sonic-logger-pretty when you're viewing it:

new SonicLogger({ env: 'production', pretty: true, color: true, timezone: 'UTC' });

Performance & resource cost

These numbers are from a real benchmark run on this exact codebase (v0.0.1-beta-6, Node v22) — not estimates.

| Operation | Cost | Notes | |---|---|---| | logger.info() (plain call) | ~2.5 µs/op (~400,000 ops/sec) | The common case — most of your logging is at info or below | | logger.debug() when filtered out by level | ~0.024 µs | Effectively free — skipped before any real work happens | | httpMiddleware() per request | ~1.92 µs/request | Negligible next to actual request handling time | | Turning on metrics / route analytics / resource monitor | +0.06 MB, one-time | Not per-request — this is the one-time cost of starting the feature | | All advanced features running together under load | Bounded, flat growth per batch | Not exponential — this is the ring-buffer design described below, confirmed under test |

The one real cost: error/warn logging

Be honest about this one: logging an error or warn with default settings costs about 23 µs/call — roughly 9x slower than a plain info call. About 18 µs of that is capturing the exact file/line/function that made the call (captureSource, on by default for warn and error).

Why this costs anything at all: getting an accurate file:line requires asking V8 to materialize a stack trace at that exact moment. This isn't something sonic-logger does inefficiently — generating a real stack trace is inherently not free in any JavaScript engine, which is exactly why it's opt-in for trace/debug/info and only on by default where it's most useful: on errors, where knowing exactly where something went wrong is usually worth far more than 18 µs.

If you don't need it, turn it off — this brings error/warn back down to about 5 µs, close to the cost of a plain call:

new SonicLogger({ captureSource: false });
// or per call-site, via a dedicated child logger:
const hotPathLogger = logger.child({}, { captureSource: false });

Does this matter for your app? For almost everyone, no — most applications log far more info lines than error lines, so this cost doesn't dominate your logger's overall throughput. It only becomes worth worrying about if a hot, high-frequency code path is logging at error level on every call. If that's happening: the better fix is usually that errors shouldn't be that frequent in the first place (something worth investigating on its own) — but if you have a legitimate reason to log at error level on a hot path, set captureSource: false on that specific logger or call.

Run this yourself, against your own configuration, with the built-in benchmarking tool — see sonic-logger/bench in the API reference, or npm run bench in this repo.


Our resource-safety guarantees

A logging library that leaks memory or grows unbounded is worse than having no observability at all — it becomes the outage. Every advanced feature in this package is built to a strict internal rule: nothing grows without a limit. Concretely, and verified by this codebase's own tests:

  • Route analytics uses a fixed-size ring buffer per route/time-window, and caps the number of distinct routes tracked at maxRoutes (default 500) — memory stays flat no matter how much traffic flows through.
  • The slow-route tracker (metrics.slowRoutes, slowRoute in createMetrics) caps the number of distinct "METHOD /path" keys tracked at maxRoutes (default 500, same option name as route analytics). Fixed in this version: a dynamic path segment the default route-key logic doesn't recognize as numeric (a UUID, a slug) used to create a permanent, never-evicted entry; past the cap, the least-recently-used route is now evicted and its stats folded into an __other__ bucket instead, so total request counts stay accurate.
  • The handle tracker's cooldown map (which prevents warning about the same leftover handle repeatedly) is capped at 500 entries — the oldest are evicted once you hit the cap, not accumulated forever.
  • The internal file-path cache used for source-location capture is capped at 500 entries for the same reason.
  • OTLP exporters (logs, traces, metrics) all use bounded buffers with eviction — a slow or unreachable collector causes old entries to be dropped and counted, never an unbounded queue.
  • Every timer this package starts (the event-loop probe, the resource monitor's interval, batched-export timers) is .unref()'d, meaning it never keeps your Node process alive by itself, and is explicitly cleared when you call .stop().
  • Every advanced feature is fully opt-in. Metrics, route analytics, the resource monitor, the handle tracker, and OTLP export cost zero CPU and zero memory unless you actually import and start them. The core logger by itself starts exactly one shared process-wide shutdown hook and nothing else.

Install footprint: ~89 KB packed, one runtime dependency (sonic-boom, ~160 KB, no native addons). Each subpath (/metrics, /otlp, /nest, /file, /view, /bench) is a separate entry point — if you never import it, its code never loads and it never runs.

Where it runs: Node.js ≥ 18 (and NestJS on top of it), server-side only — it uses Node built-ins (fs, os, perf_hooks) so it does not run in a browser or edge runtime.

No eval, no dynamic require, no telemetry or phone-home, MIT licensed.

Security

✅ 0 known vulnerabilities — npm audit is checked clean before every publish. If a dev-only dependency ever carries an unpatched advisory, it's pinned via npm's overrides rather than left unaddressed, even though it never ships in the published package. Published from CI, gated by lint + type-check + tests + build, with a publish token that's never committed to the repo. See SECURITY.md.


Full API reference

A complete map of every export, for quick lookup or for another tool to build against. Each subpath below only loads if you actually import it.

sonic-logger (core)

| Export | Purpose | |---|---| | SonicLogger | The logger class — see All configuration options. | | getLogger(options?) / setLogger(instance) / resetLogger() | Process-wide shared logger instance. | | createTransport(fn, options?) | Wrap any (record) => void \| Promise<void> function as a transport. | | registerClassifier(fn) | Add your own error-category rule, checked before the built-in ones. | | serializeError(err, options) | The same Error→JSON logic the logger uses internally, usable standalone. | | safeStringify(value, options?) | Circular-safe, depth/length-capped JSON stringify. | | formatSourceLabel(source) / formatStackFrame(frame) | Turn a captured source/stack-frame object into the ClassName#method string format used in pretty output. | | runWithContext(ctx, fn) / getContext() / updateContext(patch) | Request-scoped context, also available from /context. | | formatTraceparent(ctx?) | Builds a W3C traceparent header from the current context, for outbound requests. | | generateRequestId() / generateSpanId() | ID generators. | | LEVEL_VALUE / LEVELS | The 6 levels and their numeric values. | | installGracefulShutdown(logger, options?) | Flush on SIGTERM/SIGINT before exit — see Graceful shutdown flush. | | createSampledLogger(logger, options) | 1-in-N log sampling wrapper — see Log sampling. |

sonic-logger/context

requestContextMiddleware(options?), `requestLoggerMiddle