Request-scoped logging
withRequestContext uses Node's AsyncLocalStorage to decorate every log inside a callback
with request metadata — request IDs, user IDs, job IDs — automatically, without passing them
down by hand.
How it works
- The Node build keeps an
AsyncLocalStoragestore keyed by the logger locator. withRequestContext(bindings, fn)mergesbindingsinto the current store, then runsfn.- Every log emitted while
fnruns sees those bindings viagetRequestLogger(). - When
fnfinishes, the store reverts — no cleanup code required.
AsyncLocalStorage refresher
Think of it as thread-local storage for Node's event loop. Each async call chain gets its own
store that flows across awaits and callbacks.
Next.js route handler
import { withRequestContext, getRequestLogger } from '@cues/sawdust/logger'
import { headers } from 'next/headers'
export async function GET(req: Request) {
const requestId = headers().get('x-request-id') ?? crypto.randomUUID()
const userId = headers().get('x-user-id') ?? 'anonymous'
return withRequestContext({ requestId, userId }, async () => {
const log = getRequestLogger().child({ route: 'GET /api/data' })
log.info('handling request')
const data = await fetchData()
log.info('responding with data', { items: data.length })
return Response.json({ data })
})
}
Every log inside the callback now carries { requestId, userId, route: 'GET /api/data' }.
Express middleware
import { withRequestContext, getRequestLogger } from '@cues/sawdust/logger'
app.use((req, res, next) => {
const requestId = req.header('x-request-id') ?? crypto.randomUUID()
const userId = req.header('x-user-id') ?? 'anonymous'
withRequestContext({ requestId, userId }, () => {
req.log = getRequestLogger() // optional: attach for handlers
next()
})
})
app.get('/health', (req, res) => {
req.log.info('health check')
res.send('ok')
})
Testing the scope
Reset the locator and supply a test logger so getRequestLogger() has something to call.
import { withRequestContext, getRequestLogger } from '@cues/sawdust/logger'
import { setLogger, resetLoggerLocator, noopLogger } from '@cues/sawdust'
beforeEach(() => {
resetLoggerLocator()
setLogger(noopLogger)
})
test('runs with scoped context', () => {
const calls: unknown[][] = []
setLogger({
...noopLogger,
info: (...args: unknown[]) => calls.push(args),
child: () => ({ ...noopLogger, info: (...args: unknown[]) => calls.push(args) }),
})
withRequestContext({ requestId: 'test-1' }, () => {
getRequestLogger().info('hello')
})
expect(calls[0][0]).toBe('hello')
})
Common pitfalls
- Create scope at the entry point only. Wrapping nested functions can overwrite context. Set it once per request.
- Always
awaitasync handlers insidewithRequestContext. Forgettingawaitreturns before the async work completes and the context vanishes. - Beware work scheduled outside the handler.
setImmediate/process.nextTickafter the response may run without the context; propagate it manually if needed.
Import source
getRequestLogger is exported from both @cues/sawdust/logger and
@cues/sawdust/request-scope. Pick one source and stay consistent across a codebase.