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
Maintainers
Keywords
Readme
sonic-logger
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
- Log levels
- Errors — automatic classification
- Request context & request logging
- Reading production logs
- Event-loop freeze detection
- Resource monitor
- Route analytics
- Error analytics
- Graceful shutdown flush
- Log sampling
- Alerting hooks
/metricsendpoint- ⚠️ Runtime control — code-level config + admin HTTP route
- File logging
- Sending logs, traces & metrics elsewhere (OTLP)
- NestJS integration
- All configuration options
- Performance & resource cost
- Our resource-safety guarantees
- Full API reference
- License
Install & quick start
npm i sonic-loggerRequires 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: NOTICEChanging 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 NestIt 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
infoif it finished on time with a 2xx/3xx status,warnon a 4xx or over-budget response,erroron a 5xx. ipresolvesx-forwarded-forfirst, 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 streamOne 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.txtFlags: --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 devEvent-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'sundefinedif you're not in a memory-limited container. Threshold checks use this when it's available, falling back tosystemwhen 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 jobSame 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 firstTurned 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 cleanupThis 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_handlesreport carriesoccurrence(how many SEPARATE times this label has fired a report, not raw handle count) andrecurring(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. Onlyrecurring(a SECOND+ occurrence for the same label) logs, which is the actually-actionable "this keeps happening" signal. SetreportFirstOccurrence: trueto 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:
- Log a detailed dump of what's happening and what's likely responsible.
- Signal that specific operation to abort (via an
AbortSignal). - Call a
terminatehook 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 summaryanalytics.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, soanalytics.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/:idvs. 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-timeerrors.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.tsbuckets traffic (hour/dayring 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 usemetrics.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 thisinstallGracefulShutdown(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 handlesSIGTERMitself 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 yourexitCode) — 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.
rateis 0..1 —1logs everything (unchanged behaviour),0logs nothing.- Deterministic in tests via an injectable
rng(defaultMath.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/onBlockedand nothing changes; each is a singleifguard, 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=jsonIncludes 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
authorizecallback (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/metricsroute'sauthorizeoption 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 shapeFields 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);GETreturns the current live config as JSON (same shape asmetrics.getConfig()).POST/PATCHapplies a partial config change (same shape asmetrics.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
authorizerejects gets401, before any config is read or changed. - Omitting
authorizeentirely throws synchronously at setup time —createAdminHandlerrefuses 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,slowRouteincreateMetrics) caps the number of distinct"METHOD /path"keys tracked atmaxRoutes(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
