This guide explains how to use the Pino-based structured logging system throughout the project.
- Consistent, structured JSON logs for server-side code (API routes, services, repositories, scripts).
- Correlation of log events per request via
reqId. - Safe logging with redaction of sensitive fields.
- Lightweight helpers for timing and API request lifecycle.
lib/logger.ts: Base logger, context management, timing helper.lib/logger-http.ts: HTTP logging utilities (httpLogger,withApiLogging).
| Name | Default | Description |
|---|---|---|
LOG_LEVEL |
info |
Pino level (`trace |
LOG_PRETTY |
true in dev |
Enables pino-pretty transport (ignored in production). |
LOG_DEST |
unset | File path destination; stdout if unset. |
LOG_ENABLED |
true |
Disable all logging if set to false. |
Use one of the following patterns depending on context:
import { getLogger, createLogger } from '@/lib/logger';
// Request-bound context (includes reqId if inside withRequestContext)
const logger = getLogger();
// Static bindings for a module/service
const bookLogger = createLogger({ module: 'BookService' });
bookLogger.info({ bookId }, 'Book fetched');Avoid storing a logger from getLogger() outside of a request lifecycle; it will not update if called outside context.
Wrap work that should have a reqId correlation ID:
import { withRequestContext, getLogger } from '@/lib/logger';
export async function handler(req: Request) {
return withRequestContext(async () => {
const logger = getLogger();
logger.info({ path: new URL(req.url).pathname }, 'Handling request');
// ...
}, req.headers.get('x-request-id') || undefined);
}Middleware already applies this for App Router requests.
For API routes prefer structured error logging:
try {
// work
} catch (err) {
const { getLogger } = require('@/lib/logger');
getLogger().error({ err }, 'Failed to process request');
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}Optionally wrap a route with withApiLogging(request, handler) if start/complete/error logs are desired:
import { withApiLogging } from '@/lib/logger-http';
export async function GET(request: Request) {
return withApiLogging(request, async () => {
// handler work
return Response.json({ ok: true });
});
}- Object first, then message:
logger.info({ bookId }, 'Book fetched'); - Use concise messages (action + context).
- Include stable identifiers:
bookId,sessionId,userId. - Use semantic levels:
debug: detailed internal state, start/end markers.info: successful high-level operations (created session, sync complete).warn: recoverable or unexpected but non-failing conditions (missing optional data).error: operation failed.fatal: unrecoverable process-level failure (rare; not yet used).
Always log errors with the err field for proper serialization:
catch (err) {
logger.error({ err, bookId }, 'Book fetch failed');
}Pino's configured err serializer outputs { type, message, stack }.
Use withTiming(label, fn) for performance spans:
import { withTiming, getLogger } from '@/lib/logger';
await withTiming('calibre.sync', async () => {
const logger = getLogger();
logger.info('Starting Calibre sync');
// work
});Generates:
debugstart log{ op, phase: 'start' }infocompletion log{ op, durationMs }errorfailure log{ op, durationMs, err }
Use createLogger({ module: 'ProgressService' }) for components where adding static bindings improves filterability.
Do not add dynamic values as static bindings.
Sensitive paths are redacted automatically (authorization, password, token). Do not log secrets explicitly. Prefer referencing metadata or IDs.
- Set
LOG_LEVEL=silentfor performance tests or noisy debugging scenarios. - Set
LOG_ENABLED=falseto disable all logging (rare; may hide important diagnostics).
Tests run with LOG_LEVEL=silent to reduce noise. If asserting log output in future, introduce an in-memory destination (future enhancement).
Before:
console.error('Failed to sync', error);After:
getLogger().error({ err: error }, 'Sync failed');Before:
console.log('Cover request:', { bookId });After:
getLogger().info({ bookId }, 'Cover request');- Using message first then object:
logger.info('msg', { obj })(incorrect) – results in treating object as extra args without structure. - Reusing a context logger outside
withRequestContextscope (losesreqId). Re-acquire viagetLogger()inside the scoped function. - Logging huge objects; instead log IDs or summary counts.
See ADR for deferred items (sampling, rotation, metrics bridge).
| Task | Approach |
|---|---|
| General logging | getLogger().info({ id }, 'Action') |
| Error logging | getLogger().error({ err, id }, 'Failure') |
| Timing | withTiming('operation', fn) |
| Request context | withRequestContext(fn, existingReqId?) |
| Child logger | createLogger({ module: 'X' }) |
| API route lifecycle | withApiLogging(request, handler) |
Adopt these conventions to maintain clarity and observability as the system grows.