Structured Logging
Log requests with slog and request IDs.
Structured Logging
middleware.SlogLogger writes one structured log record per request using
log/slog.
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
app.Use(
middleware.RequestID(),
middleware.SlogLogger(logger),
)Each record contains:
| Attribute | Value |
|---|---|
method | HTTP method |
path | Request path |
status | Response status |
duration | Request duration |
remote_ip | Client IP |
request_id | ID from RequestID, when present |
The message is request. The level is Info by default.
Options
app.Use(middleware.SlogLogger(logger, middleware.SlogLoggerOptions{
Level: slog.LevelDebug,
SkipPaths: []string{"/healthz", "/readyz"},
}))| Option | Description |
|---|---|
Level | Log level for request records |
SkipPaths | Exact paths that produce no log record |
Use RequestID before SlogLogger so the request_id attribute is present.
JSON output
With a JSON handler, a record looks like:
{
"time": "2026-11-06T12:00:00.000Z",
"level": "INFO",
"msg": "request",
"method": "GET",
"path": "/api/users",
"status": 200,
"duration": 1200000,
"remote_ip": "203.0.113.10",
"request_id": "8f14e45fceea167a5a36dedd4bea2543"
}duration is encoded by slog as nanoseconds in JSON output.
remote_ip is the direct peer address. Ply does not trust
X-Forwarded-For or X-Real-IP by default. When the service runs behind a
proxy, set the client address at the proxy or add a middleware that validates
the forwarded header.
Logger ownership
The middleware does not create a logger or write to standard output. Pass the logger from the application so its handler, level, and output are controlled in one place.
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
logger := slog.New(handler)SlogLogger panics if the logger is nil.
Existing logger
The built-in middleware.Logger prints human-readable colored lines for
development. Use SlogLogger when logs are collected by a service and need
machine-readable fields. Running both doubles request logs.

Ply