feat(observability): configurable logging, request ids and error logs
- internal/logging: LOG_LEVEL/LOG_FORMAT parsing and slog handler - request_id middleware (X-Request-ID or generated, echoed) + debug access log - httpx registers errors; middleware logs internal causes with request_id - health excluded, objects/sse debug-only, no query/body/secrets in logs - new observability capability spec; README/.env.example docs
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"galo/internal/auth"
|
||||
"galo/internal/httpx"
|
||||
"galo/internal/logging"
|
||||
)
|
||||
|
||||
const ctxRequestID = "logging.request_id"
|
||||
|
||||
// requestIDMiddleware assigns a request identifier, echoes it in the response
|
||||
// header and stores it in the request context for downstream logging.
|
||||
func requestIDMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.GetHeader("X-Request-ID")
|
||||
if !logging.ValidRequestID(id) {
|
||||
id = logging.NewRequestID()
|
||||
}
|
||||
c.Set(ctxRequestID, id)
|
||||
c.Header("X-Request-ID", id)
|
||||
c.Request = c.Request.WithContext(logging.WithRequestID(c.Request.Context(), id))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// accessLogMiddleware writes one debug-level line per completed request and
|
||||
// logs collected errors. The query string, Authorization header and bodies are
|
||||
// never logged.
|
||||
func accessLogMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
if route := c.FullPath(); shouldLogAccess(route) {
|
||||
logRequest(c, start, route)
|
||||
}
|
||||
logErrors(c)
|
||||
}
|
||||
}
|
||||
|
||||
func shouldLogAccess(route string) bool {
|
||||
return route != "" && route != "/api/health"
|
||||
}
|
||||
|
||||
func logRequest(c *gin.Context, start time.Time, route string) {
|
||||
slog.Default().LogAttrs(c.Request.Context(), slog.LevelDebug, "http request",
|
||||
slog.String("method", c.Request.Method),
|
||||
slog.String("route", route),
|
||||
slog.Int("status", c.Writer.Status()),
|
||||
slog.Duration("latency", time.Since(start)),
|
||||
slog.Int("bytes", c.Writer.Size()),
|
||||
slog.String("client_ip", c.ClientIP()),
|
||||
slog.String("user_id", auth.UserID(c)),
|
||||
slog.String("request_id", logging.RequestID(c.Request.Context())),
|
||||
)
|
||||
}
|
||||
|
||||
func logErrors(c *gin.Context) {
|
||||
for _, ginErr := range c.Errors {
|
||||
level := slog.LevelError
|
||||
var appErr *httpx.AppError
|
||||
if errors.As(ginErr.Err, &appErr) && appErr.Status < 500 {
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
slog.Default().LogAttrs(c.Request.Context(), level, "request error",
|
||||
slog.String("error", ginErr.Err.Error()),
|
||||
slog.String("route", c.FullPath()),
|
||||
slog.String("request_id", logging.RequestID(c.Request.Context())),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user