Logger
Structured logging with typed context that flows through your app via ALS
Structured logging with typed context that flows through your entire app via ALS.
The problem
Unstructured logging makes it difficult to correlate events in production:
// Scattered console.logs with no structure
console.log("fetching user", userId)
console.log("user not found")
console.log("retrying request")Without structured context, there is no way to connect related log lines across a request. The typical workaround is manually threading identifiers through every function:
async function getUser(id: string, requestId: string) {
console.log(`[${requestId}] fetching user ${id}`)
// now every function needs requestId threaded through
}This is the same dependency-threading problem we solved with Define.Service.
The solution
Define one Logger for your app. The type parameter defines your context shape — the fields that flow through your app via ALS. You pass your log handlers to Define.Logger, mapping to whatever backend you use:
import { Define } from "within-ts"
const pino = createPino({ /* ... */ })
const Logger = Define.Logger<{
requestId?: string
userId?: string
jobId?: string
}>({
trace(entry) { pino.trace(entry) },
debug(entry) { pino.debug(entry) },
info(entry) { pino.info(entry) },
warn(entry) { pino.warn(entry) },
error(entry) { pino.error(entry) },
fatal(entry) { pino.fatal(entry) },
})Define.Logger provides the ALS context machinery. Your handlers receive a LogEntry that already has the ALS context merged in — you just forward it to your backend. Handlers are optional — unimplemented levels throw if called.
Every log method accepts two input forms — a plain string, or a structured object:
// Simple — just a message
Logger.info("Starting application...")
// {"level":"info","msg":"Starting application..."}
// Structured — message, untyped data, and typed context
Logger.info({
message: "Processing order",
data: { orderId: "123", itemCount: 3 }, // untyped, per-call
context: { requestId: "abc", userId: "456" }, // typed, matches Logger's type parameter
})
// {"level":"info","msg":"Processing order","orderId":"123","itemCount":3,"requestId":"abc","userId":"456"}Add context at boundaries with Logger.with. It accumulates through ALS — every log call inside the scope includes it automatically:
// In middleware — set requestId for the entire request
app.use((req, res, next) => {
Logger.with({ requestId: req.id }, () => next())
})
// Deeper in the code — add userId
async function handleRequest(req) {
Logger.with({ userId: req.user.id }, async () => {
Logger.info("handling request")
// {"level":"info","msg":"handling request","requestId":"abc","userId":"456"}
await processOrder(req.body)
})
}
// Any file, any depth — ALS context is just there
function processOrder(order) {
Logger.info({ message: "processing order", data: { orderId: order.id } })
// {"level":"info","msg":"processing order","orderId":"123","requestId":"abc","userId":"456"}
}Why typed context?
The type parameter on the Logger class enforces that both .with() and the context field only accept known fields. This prevents your team from writing requestId in one file and request_id in another:
Logger.with({ requestId: "abc" }, () => { ... }) // works
Logger.with({ request_id: "abc" }, () => { ... }) // type error
Logger.info({ message: "hello", context: { requestId: "abc" } }) // works
Logger.info({ message: "hello", context: { request_id: "abc" } }) // type errorThe data field stays untyped — it's for one-off per-call fields like orderId or itemCount that don't need cross-codebase consistency.
Swappable in tests
Just create a different logger for tests:
const logs: LogEntry[] = []
const TestLogger = Define.Logger<{ requestId?: string }>({
trace(entry) { logs.push(entry) },
debug(entry) { logs.push(entry) },
info(entry) { logs.push(entry) },
warn(entry) { logs.push(entry) },
error(entry) { logs.push(entry) },
fatal(entry) { logs.push(entry) },
})Alternatively, swap the transport in your production Logger to redirect output during tests.