@svayam-opensource/svm-util-log
v1.1.0
Published
Svayam's shared logging framework — the logger from svm-util, with only the dependencies a logger needs.
Readme
@svayam-opensource/svm-util-log
Structured logging for Node applications and command-line tools.
Every line carries where it came from, so a log you receive from someone else can be read back to the code that wrote it. Debug output is switched on from the environment, so the person in front of the problem can turn it on without a rebuild. Secrets are masked before anything is written.
npm install @svayam-opensource/svm-util-logNode 20+. TypeScript types included. Works from both import and require.
Quick start
import { createApplicationLogger, LogLevel } from "@svayam-opensource/svm-util-log";
const logger = createApplicationLogger({
appId: "gov-work",
logDir: "/Users/me/.gov/logs",
methods: [
{ METHOD: "console", LOGLEVEL: LogLevel.warn }, // on screen: warnings and errors
{ METHOD: "file", LOGLEVEL: LogLevel.debug }, // on disk: everything
],
});
logger.log(LogLevel.info, "starting up", "myapp:cli:main", "start");
logger.log(LogLevel.error, "could not reach the registry", "myapp:net:client", "fetch", 0, { status: 503 });
logger.close(); // flush before the process exitsThat is the whole setup. Everything below is detail you can come back for.
What a line looks like
2026-09-09T03:41:02.240Z warn gov-work:cli:agent-install signIn 7 no browser on this machine — offering the key route
2026-09-09T03:41:02.367Z debug gov-work:cli:sign-in-choice captureAgentKey 7 chose the api-key route meta={ user: 'rk', apiKey: '***' }
2026-09-09T03:41:02.494Z error gov-work:cli:agent-install run 7 npm could not install the agent meta={ code: 127 }Columns are fixed-width and the origin comes early, because the usual thing to do with a log is scan one column for one module, not read it as prose.
| Column | From | What it is for |
|---|---|---|
| 2026-09-09T03:41:02.240Z | automatic | ISO timestamp, UTC |
| warn | lvl | severity — see Levels |
| gov-work:cli:agent-install | pgm_cd | the code path — which module wrote this |
| signIn | fn_cd | which function inside it |
| 7 | act_id | ties one unit of work together across modules |
| no browser on this machine… | msg | your message |
| meta={ … } | meta | structured detail, masked |
Naming pgm_cd — the one convention worth following
Use the code path: <package>:<directory>:<module>.
gov-work:cli:sign-in-choice → src/cli/sign-in-choice.ts
myapp:net:client → src/net/client.tsA reader who receives your log can then open the file without guessing, and — as the next section shows — can switch that exact module on by name.
Turning on debug output in the field
Errors and warnings are always written. Everything below them is off until someone asks
for it, through the standard Node NODE_DEBUG variable:
NODE_DEBUG=gov-work:cli:sign-in-choice gov work # one module
NODE_DEBUG=gov-work:cli:* gov work # one directory
NODE_DEBUG=gov-work:* gov work # the whole applicationThis matters because the person who needs the log is usually not the person who can reproduce the fault. You send them one environment variable; they reproduce it and send back the file. No debug build, no release, no code change.
flowchart TD
A["logger.log(level, msg, pgm_cd, …)"] --> B{"level is<br/>error or warn?"}
B -- yes --> D["write it"]
B -- no --> C{"NODE_DEBUG matches<br/>pgm_cd, fn_cd or act_id?"}
C -- yes --> D
C -- no --> E["dropped, costs nothing"]
D --> F{"for each configured<br/>transport"}
F --> G["level within<br/>this transport's LOGLEVEL?"]
G -- yes --> H["mask meta → format → write"]
G -- no --> ETwo gates, and they are different questions. NODE_DEBUG decides whether this line exists
at all; a transport's LOGLEVEL decides where an existing line goes. That is how the
quick-start config keeps the screen quiet at warn while the file keeps everything.
Levels
Ordered least to most verbose. A transport set to a level accepts that level and everything above it.
| Level | Use it for |
|---|---|
| error | the operation failed — always written |
| warn | something is wrong but the run continues — always written |
| info | milestones a user would recognise |
| http | requests and responses |
| verbose | more than info, less than a trace |
| debug | what you would want during an incident |
| silly | everything |
import { withinLevel, LogLevel } from "@svayam-opensource/svm-util-log";
withinLevel(LogLevel.error, LogLevel.info); // true — error passes an info threshold
withinLevel(LogLevel.debug, LogLevel.info); // false — debug does notKeeping secrets out of logs
A log is a file. It gets copied and sent to other people — precisely when something has gone wrong. So masking happens in the library, before rendering, not in your memory at each call site.
logger.log(LogLevel.debug, "signing in", "myapp:auth:login", "run", 0, {
user: "rk",
apiKey: "sk-live-9f3a",
db: { host: "localhost", password: "hunter2" },
});… signing in meta={ user: 'rk', apiKey: '***', db: { host: 'localhost', password: '***' } }Matching is by field name, case-insensitive, at any depth, and matches names that
contain a masked word — so userPassword, db_pwd and authToken are all covered.
Covered out of the box:
password passwd pwd secret token apiKey api_key authorization
auth credential credentials privateKey private_key sessionId
session_id cookie jwt bearerExtend the default, never replace it:
import { DEFAULT_MASK } from "@svayam-opensource/svm-util-log";
createApplicationLogger({
appId: "myapp",
logDir: "/var/log/myapp",
methods: [{ METHOD: "file", LOGLEVEL: LogLevel.info }],
mask: [...DEFAULT_MASK, "panNumber", "aadhaar"],
});Passing mask: ["panNumber"] on its own would silently switch off every default — the one
mistake worth watching for here.
Masking protects values inside
meta. It cannot see a secret you interpolated into the message string yourself, so put values inmeta, not inmsg.
Where lines go
Two transports ship in this package.
console
Writes to stderr, coloured by level. stderr because stdout is your program's answer — a
caller running myapp | jq should not receive log lines as data.
{ METHOD: "console", LOGLEVEL: LogLevel.warn }file
A daily-rotating file, <date>-<appId>.log, in logDir.
{
METHOD: "file",
LOGLEVEL: LogLevel.debug,
DATA_STORE: {
dirname: "/var/log/myapp", // defaults to logDir
datePattern: "YYYY-MM-DD",
zippedArchive: true,
maxSize: "20m",
maxFiles: "14d", // older files are deleted
},
}Defaults are 20 MB per file, gzipped, kept a fortnight — long enough to diagnose something reported today, short enough that a forgotten log directory is not a growing disclosure risk.
Adding your own
database and api transports are not in this package — they would drag a database
driver and an HTTP client into every install. Register one instead:
import { registerTransport } from "@svayam-opensource/svm-util-log";
import Transport from "winston-transport";
class DbTransport extends Transport { /* … */ }
registerTransport("database", (conf, config) => new DbTransport(conf, config));Register before you call createApplicationLogger. A method named in your config with
nothing registered for it is ignored, not an error — the same config file can move between
a service that has a database and a CLI that does not, and the CLI simply writes fewer places.
flowchart LR
L["logger.log(…)"] --> M["mask meta"] --> F["format one line"]
F --> C["console → stderr"]
F --> D["file → daily rotate"]
F --> X["your transport<br/><i>registerTransport</i>"]
style X stroke-dasharray: 4 3Configuration reference
createApplicationLogger({
appId, // string — names the app in every line and in the filename
logDir, // string — where file transports write by default
methods, // array — one entry per destination
mask, // string[] — optional; extend DEFAULT_MASK
inspect, // optional — { depth?: number; maxStringLength?: number } for meta rendering
});| Field | Required | Notes |
|---|---|---|
| appId | yes | appears as app_cd and in the log filename |
| logDir | yes | must exist or be creatable; used when a file method sets no dirname |
| methods | yes | must name at least one destination — see the note below |
| mask | no | defaults to DEFAULT_MASK; extend it, don't replace it |
| inspect | no | defaults to depth 4, 512-character strings |
Give
methodsat least one entry. An empty array does not throw, but it is not silence either: winston then prints its ownAttempt to write logs with no transportswarning for every line, with the entry as raw JSON. That output bypasses this library's formatting — the masking still applies, becausemetais masked on the way in, but the result is noisy and not what you configured.logger.toLogObject().ENABLED_TRANSPORTSshows what is really attached.
Configuration is passed in, never read from a global. Nothing has to be initialised before you can log, which matters most on a program's first run — the moment you most want a log and are least likely to have configuration.
API
| Export | What it does |
|---|---|
| createApplicationLogger(config) | returns an ApplicationLogger |
| registerTransport(method, factory) | adds a destination this package does not ship |
| LogLevel, LogLevelIndex, logLevelIndex | the level names, their order, their index |
| LogMethod | console · file · database · api |
| withinLevel(level, threshold) | is level within threshold? |
| DEFAULT_MASK | the field names masked by default |
| formatLogMessage(entry, config?) | render one line — useful in your own transport |
| toLogEntry(appId, lvl, msg, …) | build the entry a transport receives |
| maskValues(value, mask?) | mask an object without logging it |
| stringify(value, config?) | the meta= renderer; never throws, handles cycles |
| isLogEnabled(pgm_cd, fn_cd?, act_id?) | is NODE_DEBUG on for this origin? |
ApplicationLogger
logger.log(level, message, pgm_cd?, fn_cd?, act_id?, meta?); // write a line
logger.toLogObject(); // what is actually enabled — for a `doctor`/`status` command
logger.toString(); // the same, as JSON
logger.close(); // flush and close every transportAlways close() before a short-lived process exits. File writes are asynchronous, so
without it the last lines of a run — the ones explaining why it ended — are the ones most
likely to be lost.
Recipes
A command-line tool
Quiet by default, everything on disk, and the user can raise the screen level themselves.
const logger = createApplicationLogger({
appId: "mycli",
logDir: path.join(os.homedir(), ".mycli", "logs"),
methods: [
{ METHOD: "console", LOGLEVEL: LogLevel.warn },
{ METHOD: "file", LOGLEVEL: LogLevel.debug },
],
});
process.on("exit", () => logger.close());A long-running service
const logger = createApplicationLogger({
appId: "orders-api",
logDir: "/var/log/orders-api",
methods: [
{ METHOD: "console", LOGLEVEL: LogLevel.info },
{ METHOD: "file", LOGLEVEL: LogLevel.http },
{ METHOD: "database", LOGLEVEL: LogLevel.error }, // ignored unless registered
],
});Following one request across modules
Give every line of one unit of work the same act_id:
const actId = nextRequestId();
logger.log(LogLevel.http, "POST /orders", "orders:api:routes", "create", actId);
logger.log(LogLevel.debug, "reserving stock", "orders:domain:stock", "reserve", actId);
logger.log(LogLevel.error, "payment declined", "orders:domain:pay", "charge", actId, { code: "card_declined" });Then grep ' 4471 ' returns that request and nothing else. NODE_DEBUG accepts an act_id
too, so a single transaction can be traced without switching on a whole module.
Troubleshooting
| What you see | Why |
|---|---|
| Nothing below warn appears | Expected. Set NODE_DEBUG — see above. |
| Log lines mixed into piped output | console writes to stderr by design. Redirect with 2>/dev/null, or read it with 2>&1. |
| No file appears | Check logDir exists and is writable, and that a file method is in methods. |
| The last lines of a run are missing | Call logger.close() before exit. |
| A field you expected to be masked is not | Masking reads field names in meta. A secret placed in the message string is not visible to it. |
| Your custom transport never writes | registerTransport must run before createApplicationLogger. |
| Attempt to write logs with no transports | methods is empty, or every entry names a transport nothing registered. Check logger.toLogObject(). |
| mask seems to have switched off the defaults | It did. Use [...DEFAULT_MASK, "yours"]. |
Notes
Console output uses winston; file rotation uses winston-daily-rotate-file.
Design rationale
Why this package is separate from @svayam/svm-util, why configuration is injected rather
than global, why console output goes to stderr, and what belongs in this package versus a
sibling — all of that is in docs/architecture.md.
That file lives in the source repository, which is private to Svayam; the link below will not open for you unless you have access. Everything in this README stands on its own, and nothing in the architecture document changes how the library behaves.
Svayam engineers: docs/architecture.md.
Licence and support
Copyright © 2026 Svayam Infoware Pvt. Ltd. Published publicly so that Svayam's own public tools — and anyone installing them — can resolve it without registry configuration.
