Child Loggers
How child loggers work
The Building a Logger lesson introduced logger.child(context). The Log Context lesson used it to create request-scoped loggers. This lesson explores child loggers in depth — nesting, overriding, and practical patterns.
The inheritance chain
// Application logger (root)
const logger = new Logger({
context: { service: "book-catalog", env: "production" },
});
// Request logger (child of root)
const requestLogger = logger.child({
requestId: "req_a1b2c3",
method: "POST",
path: "/v2/orders",
});
// Operation logger (child of request)
const orderLogger = requestLogger.child({
orderId: "order-99",
});
orderLogger.info("processing payment");
// {"service":"book-catalog","env":"production","requestId":"req_a1b2c3","method":"POST","path":"/v2/orders","orderId":"order-99","level":"info","message":"processing payment"} Each child inherits everything from its parent and adds its own context. The order logger includes service, env, requestId, method, path, AND orderId — all without passing them explicitly.
Scoping to a database operation
route.post("/v2/orders", {
resolve: async (c) => {
const log = c.locals.log; // request-scoped logger
log.info("creating order");
const order = createOrder(c.input.body);
const orderLog = log.child({ orderId: order.id });
orderLog.info("order created", { total: order.total, items: order.items.length });
try {
const payment = await chargeCard(order.total, c.input.body.paymentToken);
orderLog.info("payment processed", { chargeId: payment.id });
} catch (err) {
orderLog.error("payment failed", { error: err instanceof Error ? err.message : String(err) });
throw err;
}
orderLog.info("order completed");
return Response.json(order, { status: 201 });
},
}); Every log entry for this order includes the orderId. If the payment fails, the error log has the request ID, user ID, AND order ID — everything needed to investigate.
Passing loggers to service functions
Service functions that are called from route handlers can accept a logger parameter:
// src/services/orders.ts
export function processPayment(
orderId: string,
amount: number,
token: string,
log: Logger,
): PaymentResult {
const paymentLog = log.child({ operation: "payment", amount });
paymentLog.info("charging card");
const result = stripe.charge(amount, token);
paymentLog.info("card charged", { chargeId: result.id });
return result;
} The service function receives a logger that already has the request and order context. It adds operation: "payment" — now every payment log includes the full chain: service → request → order → payment.
When NOT to create a child
Not every function call needs a child logger. Create children when you add meaningful context (orderId, operation type). Do not create children just to pass the logger along — that adds overhead without value.
// Unnecessary: no new context
const childLog = log.child({}); // Don't do this
// Useful: adds orderId context
const orderLog = log.child({ orderId: order.id }); // Do this Exercises
Exercise 1: Create a three-level logger chain: root → request → order. Log from each level. Verify context accumulates.
Exercise 2: Pass a child logger to a service function. Verify the service’s logs include the request context.
Exercise 3: Trace a failed request by searching for its request ID. Verify all related logs (request, order, payment, error) appear.
Why pass a logger to service functions instead of importing the global logger?