@xeplr/logs
v1.0.6
Published
Text logger with 5 levels, daily rotation, session IDs, and email alerts
Maintainers
Readme
@xeplr/logs
A dependency-free text logger for xeplr services. Five levels, a console line for everything, and a buffered store for the lines worth keeping: a daily app file, one file per unit of work, or rows in a database your app owns. It can also hand errors to a function of yours (to make them rows on an error screen) and email critical ones.
Use it when you want to answer two questions about every log line: what is it about (referenceId — a job occurrence, a movement, a run) and where should it be kept (to — app, session or db).
var log = logs.createLogger({ referenceId: occurrenceId, to: 'session' })
log.info('Done — 1000 read, 980 loaded', { read: 1000, loaded: 980 })Install
npm i @xeplr/logsNo dependencies, no peers. Uses only fs and path.
Quick start
var express = require('express')
var logs = require('@xeplr/logs')
logs.configure({
appName: 'myapp',
logDir: './logs',
isDev: process.env.NODE_ENV !== 'production',
retentionDays: 30
})
var app = express()
app.use(logs.requestLogger()) // req.log, and one line per request in and out
var log = logs.createLogger('startup')
log.info('listening', { port: 3000 })
process.on('SIGTERM', async function () {
await logs.destroy() // write whatever is still buffered
process.exit(0)
})API
| export | what |
|---|---|
| configure(config) | Merge settings into the module's config. Call again at runtime to change a level — no restart. |
| createLogger(referenceIdOrOptions) | A logger bound to one unit of work: createLogger('jobs') or createLogger({ referenceId, to }). |
| requestLogger() | Express middleware: sets req.referenceId, req.sessionId, req.log, and logs → METHOD url / ← METHOD url status ms. |
| purge(retentionDays?) | Delete this app's .log files last written more than N days ago. Resolves to the number deleted. |
| flush() | Write everything buffered now. Resolves when done. |
| destroy() | Stop the flush and purge timers, then flush(). Await it on shutdown. |
| LEVELS | { debug: 1, info: 2, important: 3, error: 4, critical: 5 } |
A logger
| member | what |
|---|---|
| debug / info / important / error / critical(message, meta?, opts?) | Log a line. meta is an object (JSON-stringified) or a string. opts.to overrides the bound destination for this line. |
| referenceId | The id the logger was created with, or null. |
| isEnabled(level) | True if a line at that level would reach the console or the store. Use it to skip building expensive metadata. |
| trace(name, fn) | Wrap a sync or async function; at debug level logs → name, ← name 12ms or ✗ name failed after 12ms: …. Errors are re-thrown. When debug is off it is a single check and a straight call. |
| span(name, fn) | trace(name, fn)() — the same, inline. |
| for(referenceId) | A new logger for another unit of work, keeping this one's to. |
configure() options
| option | default | meaning |
|---|---|---|
| appName | 'app' | Prefix of every file name, and the id on lines with no referenceId. |
| logDir | './logs' | Directory for files. Created when appFile is on or defaultTo is 'session'. |
| isDev | true | Only used to derive consoleLevel when that is not set. |
| consoleLevel | null → debug if isDev, else important | Lowest level printed to stdout/stderr. |
| storeLevel | 'info' | Lowest level written to the store (file or db). Independent of the console. |
| defaultTo | 'app' | Destination for a logger that has a referenceId but no to. |
| appFile | true | Write <appName>-<date>.log. false drops lines bound for app. |
| fileFormat | 'text' | 'text' or 'ndjson' (one JSON record per line — see Files). |
| store | null | async function (rows) for to: 'db'. Called with an array of records per flush. |
| flushMs | 1000 | Flush timer. 0 disables the timer (flushes then happen at maxBatch or on flush()). |
| maxBatch | 200 | Flush as soon as this many records are buffered. |
| onError | null | function (entry) called for lines at sinkThreshold or above. null switches it off. |
| sinkThreshold | 4 (error) | Lowest level passed to onError. |
| emailTo | null | Address or array of addresses for alerts. |
| emailService | null | Object with send(to[], subject, html). Both emailTo and emailService are needed. |
| emailThreshold | 5 (critical) | Lowest level emailed. |
| retentionDays | null | Set it and purge(retentionDays) runs every hour. null never deletes anything. |
| fatalThreshold | 4 | Accepted for backward compatibility; not used. |
Where a line goes
Every line goes to the console if its level is at or above consoleLevel — level error and above via console.error, the rest via console.log. The console is written immediately, never buffered.
A line goes to the store if its level is at or above storeLevel, to the destination chosen like this:
| the line has | destination |
|---|---|
| opts.to on the call, or to on the logger | that one |
| a referenceId, no to | defaultTo (app unless configured) |
| no referenceId, no to | app |
| destination | kept in | if it cannot be used |
|---|---|---|
| app | <logDir>/<appName>-<YYYY-MM-DD>.log (UTC date) | dropped from the store when appFile: false |
| session | <logDir>/<appName>.<referenceId>.log — no date, one file per run | falls back to app when there is no referenceId |
| db | store(rows) | console only, with a one-time warning, when there is no referenceId or no store |
An unknown to on a single call is kept on the console only, with a one-time warning. An unknown to, or to: 'db' without a referenceId, passed to createLogger() throws.
Files
Text lines look like the console:
[2026-08-21T06:27:35.041Z] [run_42] [IMPORTANT] Done — 1000 read, 980 loaded {"phase":"summary","read":1000}With fileFormat: 'ndjson' each line is one record, the same fields store receives:
{"at":"2026-08-21T06:27:35.041Z","level":"IMPORTANT","appName":"demo","referenceId":"run_42","message":"Done — 1000 read, 980 loaded","meta":{"phase":"summary","read":1000,"loaded":980,"dropped":20}}store(rows)
Each row: { to: 'db', at, level, levelNum, appName, referenceId, message, meta } — at is epoch milliseconds, level the lowercase name. If store throws or rejects, that batch is dropped with a one-time console.error warning; the lines are already on the console.
onError(entry)
{ level, levelLabel, appName, sessionId, referenceId, message, meta, at } — at is a Date; sessionId and referenceId hold the same value (the referenceId, or appName when there is none). Called after console, store and email. Its return value is not awaited and anything it throws or rejects is swallowed.
Email alerts
Subject <LEVEL>: [appName] [referenceId or appName] <first 80 chars of message>, an HTML body with the level, app, reference, message and metadata. emailService.send is called synchronously and its errors are swallowed.
Routes
None. requestLogger() is middleware, not a router. It reads the request id from the x-reference-id header, or x-session-id, and uses null when neither is present. Response lines are logged at error for 5xx, important for 4xx, info otherwise.
Environment variables
None. Everything is passed to configure().
Database / migrations / CLI
None. The package owns no database — to: 'db' calls the store function you supply — and ships no CLI.
Rules the code enforces, and why
| rule | why |
|---|---|
| The console gets every line (above consoleLevel) immediately; only the store is buffered. | A process that dies with lines pending loses them — and those are the lines explaining why. Printed first, they are already with whatever captures stdout. |
| The level check is the first thing a log call does; formatting waits for flush time. | A disabled log.debug costs one integer comparison. The buffer holds raw fields, not finished strings. |
| File and db writes are batched (flushMs / maxBatch). | Fewer syscalls and context switches on small machines; for a database, one insert instead of one round trip per line. |
| No referenceId means no referenceId. requestLogger never invents one. | An unkeyed line must not look like a keyed one — that distinction is what every storage decision is based on. |
| defaultTo is app, not session. | Existing call sites pass a category (createLogger('jobs')), not a run id. A session file for a category would grow forever and purge() (by mtime) could never reclaim it. Ask for to: 'session' with a real run id. |
| A referenceId is sanitised before it becomes a file name ([^A-Za-z0-9._-] → _, runs of dots → _, max 120 chars). | Ids come from callers this package does not control; / or .. would write outside logDir. |
| Configuration mistakes throw in createLogger(); the same mistake on one call degrades to console. | A logger built wrong is a bug found immediately; one bad line is not worth failing the request it was logging. |
| Metadata that cannot be JSON-stringified (e.g. circular) becomes [unserialisable metadata: …]. | A log call must not throw, least of all while something is already failing. |
| onError is fire-and-forget and swallows errors; it runs last. | Log calls are synchronous everywhere, and a broken sink must not replace the real error or delay anything else. |
| Nothing is purged unless retentionDays is set. purge() only touches <appName>-*.log and <appName>.*.log, by modification time. | A library must not start deleting an app's files on upgrade, nor another app's files in a shared directory. Mtime works for both dated and per-run files. |
| Purge runs hourly, not daily. | A daily timer in a process restarted every few hours never fires. |
| All timers are unref()ed. | Logging never keeps a process alive. |
| flush() while a flush is running returns the same promise, and re-arms if more arrived meanwhile. | Two overlapping flushes would write records twice; a burst during a flush would otherwise sit in memory. |
Tests
There is no test suite yet: package.json has no test script and there is no test/ directory. CI (.github/workflows/ci.yml) reports "No test script — nothing verified."
License
MIT
