Logging
The IAM service uses Pino for all logging. There are two distinct logger instances: an application logger for general-purpose messages (auth decisions, token operations, anomaly events), and an HTTP logger for per-request lifecycle tracking (method, status, latency, IP). Both write to files inside the auth-logs/ directory, both redact sensitive fields before anything reaches disk, and both attach structured metadata so every log entry carries machine-readable context.
The application logger is a singleton. It is created once at startup and shared across the entire process through getLogger(). Every module that needs to log creates a child logger from that singleton, tagging entries with a service and branch field so you can filter by domain (tokens, OAuth, MFA, anomalies) without splitting into separate files.
The HTTP logger is an Express middleware powered by pino-http. It wraps each request in a log entry that records timing, status, client IP, user agent, and the full URL. It assigns a unique request ID to every inbound request and propagates it in the X-Request-Id response header.
Log Directory
All log files live under a single directory. The service resolves the path in this order:
- If the
LOG_DIRenvironment variable is set, use that value. - Otherwise, default to
${PROJECT_ROOT}/auth-logs.
The directory is created automatically at startup if it does not exist. Four files are written:
| File | Logger | Minimum Level | Contents |
|---|---|---|---|
info.log | Application | info | Everything at info level and above (info, warn, error, fatal) |
warn.log | Application | warn | Warnings and errors only (warn, error, fatal) |
errors.log | Application | error | Errors and fatal entries only (error, fatal) |
http.log | HTTP | info | One entry per HTTP request (status, latency, IP, URL) |
The first three files are fed by the application logger through Pino's transport pipeline. Each transport targets a different file with a different minimum level, so the same application log entry can appear in info.log, warn.log, and errors.log simultaneously if its level qualifies. The HTTP logger writes exclusively to http.log through its own separate Pino instance.
errors.log during debugging to focus on failures. Tail http.log to see traffic patterns and latency spikes. info.log is the most verbose file and contains the union of all application-level activity.Application Logger
Singleton Pattern
The application logger is created by getLogger(). On the first call, it reads the logLevel field from the service configuration, builds a Pino instance with three file transports, and caches it. Every subsequent call returns the same instance.
import { getLogger } from '@riavzon/auth'
const log = getLogger()
log.info({ userId: 42 }, 'Token generated')
Configuration
The logLevel field in the service configuration controls the minimum severity for the application logger. It accepts any standard Pino level.
trace, debug, info, warn, error, fatal. Optional. Defaults to the Pino default (info) when omitted.Transports
The logger uses pino.transport() with three pino/file targets:
| Target | Destination | Level |
|---|---|---|
| 1 | auth-logs/info.log | info |
| 2 | auth-logs/warn.log | warn |
| 3 | auth-logs/errors.log | error |
Each target is a Pino file transport that appends newline-delimited JSON. The targets run in worker threads managed by Pino's transport system, so log writes do not block the main event loop.
Timestamps
Every log entry carries an ISO 8601 timestamp generated by pino.stdTimeFunctions.isoTime. This produces values like "2025-01-15T14:30:00.000Z" rather than Unix epoch milliseconds.
Uptime Mixin
A mixin function injects process.uptime() into every log entry. Both the application logger and the HTTP logger include this field. It records how many seconds the Node.js process has been running at the moment the log line was emitted. It helps correlate events to process lifecycle phases (startup, steady state, post-restart).
{
"level": 30,
"time": "2025-01-15T14:30:00.000Z",
"uptime": 842.331,
"msg": "Token generated"
}
Redaction
Both loggers aggressively redact sensitive data. Pino replaces the value of any matching path with [SECRET] before the entry reaches the transport layer - the raw value never appears in the log file.
Application Logger Redaction Paths
| Path | What it protects |
|---|---|
req.headers.authorization | Bearer tokens in request headers |
user.password | Raw or hashed passwords on user objects |
accessToken | Signed JWT access tokens |
refresh_token | Raw refresh token strings |
cookie | Full cookie header strings |
cookies | Parsed cookie objects |
canary_id | Bot-detector visitor identifier |
req.cookies | Parsed cookies from the request |
req.cookie | Raw cookie header from the request |
access_token | Access tokens in any property name |
email | User email addresses |
name | User names |
*.secret | Any nested property named secret (wildcard path) |
HTTP Logger Redaction Paths
| Path | What it protects |
|---|---|
req.headers.authorization | Bearer tokens in request headers |
req.cookies.session | The refresh token cookie value |
email, name, cookie, or secret, Pino replaces it with [SECRET] automatically. Be aware of this behavior when adding new log calls with domain-specific objects.Child Loggers
Modules and controllers do not use the root logger directly. Instead, each creates a child logger that inherits all transports, redaction, and formatting from the parent but adds contextual fields. This pattern makes it possible to filter logs by service domain, branch, or request type without modifying the log destination.
Creating a Child Logger
const log = getLogger().child({ service: 'auth', branch: 'access token' })
log.info('New access token issued')
// Output includes: { "service": "auth", "branch": "access token", ... }
Naming Conventions
The service field identifies the subsystem. The branch field narrows it to a specific operation. Some branches add extra fields for request-level tracing:
| service | branch | Extra fields | Used by |
|---|---|---|---|
auth | access token | Access token generation and verification | |
auth | refresh tokens | Refresh token lifecycle (generate, verify, revoke, rotate) | |
auth | classic | type: 'login' or type: 'signup' | Login and signup controllers |
auth | tempLinks | type: 'signature' | Magic link signature verification |
auth | mfa | visitorId | MFA code generation and verification |
auth | anomalies | visitor_cookie, ip | Anomaly detection engine |
auth | oauth | OAuth provider flow | |
auth | logout | Logout and session teardown | |
auth | content guard | Content security enforcement | |
auth | password-reset | Password change flow | |
auth | routes | type: 'Json checker' | Request body validation middleware |
auth | utils | type: 'sanitizeInputString' | HTML sanitization utility |
HMAC | clientId | HMAC signature verification |
http.log by request ID, then cross-reference with info.log entries where branch = "mfa" and visitorId matches. The combination of HTTP and application logs covers both the request lifecycle and the business logic decisions.HTTP Logger
The HTTP logger is an Express middleware that wraps every inbound request in a structured log entry. It is the first middleware mounted in the Express chain, so it captures the full lifecycle of every request including those rejected by later middleware.
Middleware Position
httpLogger → helmet → headers → validateIp → hmacAuth? → json → cookieParser → routes
Because httpLogger runs before everything else, every request gets a log entry regardless of whether it passes authentication, HMAC validation, or body parsing.
Request ID
The HTTP logger generates a unique identifier for every request and exposes it in the response:
Check for existing ID
If req.id is already set (by an upstream proxy or load balancer), the logger uses it.
Check the header
If req.id is not set, the logger checks for an x-request-id header. If present, it uses that value.
Generate a new ID
If neither source provides an ID, the logger generates one with crypto.randomUUID() (UUIDv4).
Set the response header
The logger always sets X-Request-Id on the response, so the client (or downstream service) can correlate its own logs to this specific request.
Log Levels by Status Code
The HTTP logger assigns a severity level to each request based on the response status code:
| Status Range | Log Level | Meaning |
|---|---|---|
500+ | error | Server error. Something broke. |
400-499 | warn | Client error. Bad input, unauthorized, rate limited. |
200-399 | info | Success. Normal operation. |
/.well-known/ | silent | Suppressed entirely. Health checks and ACME challenges produce no log output. |
/.well-known/ paths are silenced to prevent health check probes from flooding http.log. This is controlled by a path check in the customLogLevel function.Asset Filtering
Static asset requests are excluded entirely from HTTP logging. The logger checks the request URL against a regex of file extensions and skips the log entry if any match:
.css, .js, .png, .jpg, .jpeg, .svg, .ico, .woff, .woff2, .ttf, .map, .webp, .json
This prevents CDN or browser cache-miss requests for static files from cluttering http.log with entries that carry no diagnostic value.
Custom Properties
Each HTTP log entry includes these additional fields beyond the standard pino-http output:
| Field | Source | Description |
|---|---|---|
ip | req.ip | Client IP address as seen by Express (respects trust proxy) |
userAgent | req.headers['user-agent'] | Raw User-Agent header string |
FullUrl | req.protocol + '://' + req.hostname + req.originalUrl | Complete URL including protocol, host, and query string |
cookies | req.headers.cookie | Raw cookie header (redacted for session via redaction paths) |
Custom Attribute Keys
The HTTP logger renames pino-http's default attribute keys to more descriptive names:
| Default Key | Custom Key |
|---|---|
req | httpRequest |
res | httpResponse |
err | httpError |
responseTime | latency |
reqId | requestId |
A typical http.log entry looks like this:
{
"level": 30,
"time": "2025-01-15T14:30:00.000Z",
"requestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"httpRequest": { "method": "POST", "url": "/auth/user/refresh-session" },
"httpResponse": { "statusCode": 201 },
"latency": 23,
"ip": "192.168.1.1",
"userAgent": "Mozilla/5.0 ...",
"FullUrl": "https://auth.example.com/auth/user/refresh-session",
"msg": "POST /auth/user/refresh-session 201 23ms"
}
Custom Messages
The HTTP logger generates context-aware log messages depending on the response status:
| Condition | Message format |
|---|---|
Status 404 | "{method} {url} {status} Referer: {referer} {latency}ms" |
Status 500+ (with error) | "{error.name}: {error.message} {error.stack}" |
| All other statuses | "{method} {url} {status} {latency}ms" |
The 404 message includes the Referer header to help trace where broken links originate. Error messages include the full stack trace so you can diagnose server errors directly from http.log without needing to correlate with errors.log.
Serializers
The HTTP logger uses pino-http's built-in req, res, and err serializers. These extract a safe subset of request and response data (method, url, headers for requests; statusCode for responses; message, type, stack for errors) while avoiding circular references and excessively large objects.
Accessing Logs
File System
# Real-time application errors
tail -f auth-logs/errors.log
# Real-time HTTP traffic
tail -f auth-logs/http.log
# Warnings and errors together
tail -f auth-logs/warn.log
# Everything (most verbose)
tail -f auth-logs/info.log
Docker Compose
When the IAM service runs inside Docker, the auth-logs/ directory is typically mounted as a volume. You can tail logs from the host:
docker compose logs -f auth
Or access the log files directly in the mounted volume:
tail -f ./auth-logs/errors.log
Filtering with jq
Since every log file contains newline-delimited JSON, you can use jq to filter entries:
# All MFA-related application logs
cat auth-logs/info.log | jq 'select(.branch == "mfa")'
# All HTTP 500 errors with latency > 100ms
cat auth-logs/http.log | jq 'select(.httpResponse.statusCode >= 500 and .latency > 100)'
# All requests from a specific IP
cat auth-logs/http.log | jq 'select(.ip == "203.0.113.42")'
# Trace a request by ID across both logs
export RID="a1b2c3d4-e5f6-7890-abcd-ef1234567890"
cat auth-logs/http.log | jq "select(.requestId == \"$RID\")"
cat auth-logs/info.log | jq "select(.requestId == \"$RID\")"
Summary
| Aspect | Application Logger | HTTP Logger |
|---|---|---|
| Library | Pino 10 | pino-http 10 |
| Instance | Singleton via getLogger() | Express middleware |
| Log files | info.log, warn.log, errors.log | http.log |
| Redaction paths | 13 (including *.secret wildcard) | 2 |
| Request ID | Not generated (use child logger fields) | Generated per request (crypto.randomUUID()) |
| Timestamps | ISO 8601 | ISO 8601 |
| Asset filtering | N/A | Excludes 13 static file extensions |
| Child loggers | Yes, per module/controller | No |
| Custom fields | uptime (mixin) | ip, userAgent, FullUrl, cookies |