@noego/logger
v0.3.1
Published
A flexible logging library for Node.js
Readme
@noego/logger
Lightweight logging for Node.js with:
- level filtering (
TRACEtoFATAL) - named loggers
- contextual metadata
- pluggable transports
- optional
@noego/iocintegration
Install
npm install @noego/loggerQuick Start
import { getLogger, configureLogging, LogLevel } from "@noego/logger";
configureLogging({
level: LogLevel.INFO,
serviceName: "my-service",
});
const logger = getLogger("api");
logger.info("Server started", { port: 3000 });
logger.error("Request failed", new Error("boom"));Context and Named Loggers
const base = getLogger("http").withContext({ requestId: "req_123" });
base.info("Incoming request");
base.named("auth").warn("Invalid token", { userId: "u_42" });Ambient Log Context
withContext() above is static: you build a child logger and pass it
around. Ambient context is the dynamic complement — fields attached to the
current execution flow that every logger.* call inside that flow carries
automatically, with no logger or id threaded through function signatures.
import { extendLogContext, getLogger } from "@noego/logger";
const logger = getLogger("orders");
// somewhere at the edge of a request
extendLogContext({ requestId: "r_1", route: "/orders/{id}" });
// anywhere deeper in the same flow — services, repositories, after awaits
logger.info("order loaded", { orderId: 42 });
// → context: { requestId: "r_1", route: "/orders/{id}", orderId: 42 }Nothing here needs async: fields are set synchronously and follow the flow
across await, timers and I/O.
Where fields live
| Layer | Set by | Lifetime |
|---|---|---|
| Scope bag | extendLogContext(fields) while a @noego/ioc execution scope is active | the scope — a request, a queue invocation, a test scope |
| Overlay | runWithLogContext(fields, fn), withLogContext(...), @LogContext(...) | the wrapped call's dynamic extent (inherit + extend, never leaks upward) |
currentLogContext() returns scope bag ← overlays merged, innermost wins.
The scope bag is keyed by the active ioc scope object (read from the channel
@noego/ioc anchors on globalThis; ioc is not imported), so two sibling
ExecutionContext.run(scope, …) calls see the same fields, and two physical
copies of @noego/logger share everything. With no ioc scope, overlays alone
act as the base. With neither, there is no context and records are unchanged.
Sticky vs scoped
// sticky: rest of the flow, including code that already returned to the caller
extendLogContext({ correlationId: body.correlationId });
// scoped: only inside the callback
await runWithLogContext({ itemId }, () => process(item));
// scoped, for a plain function
export const handle = withLogContext((req: Req) => ({ requestId: req.id }), async (req: Req) => { ... });
// scoped, for a method — a static object or a function of the call's arguments
class Controller {
@LogContext({ action: "health" })
health() { ... }
@LogContext((req: Req) => ({ correlationId: req.body?.correlationId }))
async login(req: Req) { ... }
}extendLogContext returns false (and does nothing) when there is nothing to
attach to; it never throws. @LogContext supports both legacy
experimentalDecorators and TC39 decorators.
How a record is built
| Call | Emitted context |
|---|---|
| logger.info("m") | { ...ambient } |
| logger.info("m", { outcome: "ok" }) | { ...ambient, outcome: "ok" } — flat; call fields win on conflict |
| logger.error("m", err) | { ...ambient, args: { name, message, stack } } |
| logger.info("m", "text") / ("m", 1, 2) | { ...ambient, args: "text" } / { ...ambient, args: [1, 2] } |
| logger.withContext({...}).info("m") | { ...ambient, ...withContextData } — withContext wins |
| no ambient context | unchanged from earlier versions |
Per-call fields are never sticky. logger.info("m", { outcome })
describes that one event; to make a field follow the flow use
extendLogContext or a scoped form above.
In a @noego/app product
Fields come from the seam that knows them, so controllers need no annotation for the common ones:
// server.ts boot hooks
requestScope: (scope, { request }) => {
extendLogContext({ requestId: crypto.randomUUID(), method: request.method });
},
onRouteMatched: ({ route }) => {
// raw pattern from Dinner/Forge, e.g. "/v1/connect/auth/{action}" — before body parsing
extendLogContext({ route: route.path, action: route.action });
},
// body-derived fields: the parser (one place) or @LogContext on the methodIn testApp, env.request() / env.host.handle() go through the real host
and get all of this; env.dinner.controller(X) builds an instance outside any
request and deliberately gets no ambient fields.
IoC Integration
import { createContainer } from "@noego/ioc";
import { registerLoggerFactory } from "@noego/logger/ioc";
const container = createContainer();
registerLoggerFactory(container, Symbol.for("app:logger"), "app");API
getLogger(name)-> logger instanceconfigureLogging(options)-> configure global managershutdown()-> close all transportsConsoleTransport-> default console transport implementationextendLogContext(fields)-> attach fields to the current flow (sticky);falseif nothing to attach torunWithLogContext(fields, fn)-> runfnwith fields layered on (scoped)withLogContext(fieldsOrDerive, fn)-> wrap a function so each call is scopedLogContext(fieldsOrDerive)-> method decorator form ofwithLogContextcurrentLogContext()-> the merged fields, orundefined
Environment Variables
LOG_LEVEL(default: inferred by library, typicallyINFO)SERVICE_NAME(default:noego)NODE_ENV(default:development)
