Log Context
The missing context problem
A route handler creates a book and logs:
{ "level": "info", "message": "book created", "bookId": "book-42" } Useful — but who created it? Which request? What endpoint? Without context, you cannot trace this log entry back to anything.
Now imagine every log entry automatically includes the request ID, user ID, and route:
{
"level": "info",
"message": "book created",
"bookId": "book-42",
"requestId": "req_a1b2c3",
"userId": "user-7",
"method": "POST",
"path": "/v2/books"
} Without adding any of those fields manually in the route handler. That is log context.
What context to attach
Always present (set in onRequest):
requestId— ties every log to a specific requestmethod— GET, POST, PUT, DELETEpath— /v2/books, /v2/books/:id
Present after authentication (set in auth logic):
userId— who made the requestrole— user, admin
Present in specific handlers:
bookId,orderId, etc. — the resource being acted on
Storing context in locals
The Building a Logger lesson showed logger.child(). Combined with Hectoday HTTP’s locals, every handler gets a context-rich logger automatically:
export const app = setup({
onRequest: ({ request }) => {
const requestId = request.headers.get("x-request-id") ?? generateRequestId();
const url = new URL(request.url);
// Create a request-scoped logger with context already attached
const requestLogger = logger.child({
requestId,
method: request.method,
path: url.pathname,
});
return {
requestId,
startTime: Date.now(),
method: request.method,
path: url.pathname,
log: requestLogger,
};
},
routes: [
/* ... */
],
}); The log property in locals is a child logger pre-loaded with request context. Every route handler uses it:
route.post("/v2/books", {
request: { body: CreateBookV2 },
resolve: (c) => {
if (!c.input.ok) {
c.locals.log.warn("validation failed", { issues: c.input.issues });
return Response.json({ error: "Validation failed" }, { status: 400 });
}
const book = createBook(c.input.body);
c.locals.log.info("book created", { bookId: book.id });
// Output: {"level":"info","message":"book created","requestId":"req_a1b2c3","method":"POST","path":"/v2/books","bookId":"book-42"}
return Response.json(book, { status: 201 });
},
}); The handler calls c.locals.log.info(...) — it only passes the book-specific context (bookId). The request context (requestId, method, path) is already there from the child logger.
Adding user context after authentication
Authentication runs in onRequest (or early in the handler). Once you know the user, enrich the logger:
onRequest: ({ request }) => {
const requestId = request.headers.get("x-request-id") ?? generateRequestId();
const url = new URL(request.url);
let userId: string | undefined;
const authHeader = request.headers.get("authorization");
if (authHeader) {
try {
const token = authHeader.replace("Bearer ", "");
const payload = verifyToken(token);
userId = payload.userId;
} catch {
// Invalid token — userId stays undefined
}
}
const requestLogger = logger.child({
requestId,
method: request.method,
path: url.pathname,
...(userId ? { userId } : {}),
});
return { requestId, startTime: Date.now(), method: request.method, path: url.pathname, userId, log: requestLogger };
}, Authenticated requests include userId in every log entry. Unauthenticated requests do not — no undefined fields cluttering the logs.
Exercises
Exercise 1: Create a request-scoped child logger in onRequest. Use it in a route handler. Verify the request context appears automatically.
Exercise 2: Add user ID to the logger context after authentication. Verify authenticated and unauthenticated requests produce different log shapes.
Exercise 3: Log a business event (book created) using c.locals.log. Verify it includes requestId, userId, method, path, AND bookId — without passing the first four manually.
Why use a child logger per request instead of passing context manually to each log call?