Language: English
Messages Are for Humans, Fields Are for Machines
Log shaping and error conversion had scattered across every controller, service, and guard; consolidating them behind a global boundary in NestJS separates human-readable messages from machine-searchable structured fields.
When each feature decides its own log format, the same decisions scatter everywhere.
Controller A ─ logger / error 整形 / status 変換
Service B ─ logger / error 整形 / status 変換
Guard C ─ logger / error 整形 / status 変換
What to log, what to redact, how to convert exceptions into HTTP responses. When each feature decides these independently, cross-cutting behavior breaks with every refactor.
Consolidating into a Global Boundary
After consolidation, it looks like this:
UseCase
│ AppException / structured event
▼
Global boundary
├─ API error response
├─ redacted structured log
└─ metric / alert
Features know neither how errors appear over HTTP nor which logger implementation is in use. Presentation and delivery moved to the boundary, shrinking the responsibilities of business modules.
Message and Field for HTTP Requests
HTTP request logs carry runtime-derived values as structured fields.
const route = `${req.method} ${(req.originalUrl || req.url).split("?")[0]}`
const status = req.res?.statusCode ?? 200
const durationMs = Date.now() - start
this.logger.info(`${route} → ${status} (${durationMs}ms)`, {
event: "http.request",
http_request_id: httpRequestId,
route,
status,
durationMs,
})
The route comes from the request, the status from the response, and the duration from the difference against the start time.
The message is for human reading, shaped so method, route, status, and duration are legible at a glance. On failure, a stable error code is emitted too.
Searching and aggregation target the structured fields. With this split, changing display wording never breaks monitoring contracts. Build monitoring that greps messages instead, and every readability improvement to the log text silently kills alerts.
The logger implementation itself sits behind a port, making Nest’s Logger and Pino swappable. Redaction runs through a single shared path.
details and logDetails on AppException
Exceptions split, at the type level, information safe to return to clients from information used only for investigation.
throw new AppException({
code: "RESOURCE_NOT_FOUND",
statusCode: 404,
details: { resource: "task" }, // client
logDetails: { taskId, cause }, // server only
})
The Global Exception Filter converts this exception into response and log exactly once.
details goes back to the client; logDetails appears only in logs.
Without the separation, every piece of investigation info you add risks leaking into the response. Afraid of leaks and adding nothing instead, you’re left with nothing but stack traces when investigating production incidents.
Based on what I presented at our achievements presentation on July 31, 2026.