Add structured logging with request ID middleware
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
// RequestIDKey is the context key for the request ID value.
|
||||
const RequestIDKey contextKey = "req_id"
|
||||
|
||||
// contextHandler wraps a slog.Handler, extracting req_id from the
|
||||
// context.Context and appending it to every log record.
|
||||
type contextHandler struct {
|
||||
underlying slog.Handler
|
||||
}
|
||||
|
||||
// NewContextHandler wraps an slog.Handler so that records get a req_id
|
||||
// attribute extracted from context.Context (if present).
|
||||
func NewContextHandler(h slog.Handler) slog.Handler {
|
||||
return &contextHandler{underlying: h}
|
||||
}
|
||||
|
||||
func (h *contextHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return h.underlying.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
func (h *contextHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
if reqID, ok := ctx.Value(RequestIDKey).(string); ok && reqID != "" {
|
||||
r.AddAttrs(slog.String("req_id", reqID))
|
||||
}
|
||||
return h.underlying.Handle(ctx, r)
|
||||
}
|
||||
|
||||
func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &contextHandler{underlying: h.underlying.WithAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (h *contextHandler) WithGroup(name string) slog.Handler {
|
||||
return &contextHandler{underlying: h.underlying.WithGroup(name)}
|
||||
}
|
||||
|
||||
// WithRequestID returns a child context carrying a request ID.
|
||||
func WithRequestID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, RequestIDKey, id)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/config"
|
||||
)
|
||||
|
||||
// NewLogger creates a slog.Logger with dual output: terminal (stderr) and
|
||||
// optional file. Terminal and file outputs use independent log levels.
|
||||
// On file-open failure, a warning is emitted to stderr and the logger
|
||||
// continues without file output.
|
||||
func NewLogger(cfg config.LogConfig) *slog.Logger {
|
||||
terminalLevel, _ := config.ParseLogLevel(cfg.Level)
|
||||
fileLevel, _ := config.ParseLogLevel(cfg.FileLevel)
|
||||
|
||||
terminalHandler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: terminalLevel,
|
||||
})
|
||||
|
||||
handlers := []slog.Handler{terminalHandler}
|
||||
|
||||
if cfg.FilePath != "" {
|
||||
f, err := os.OpenFile(cfg.FilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
slog.New(terminalHandler).Warn("failed to open log file, continuing without file output",
|
||||
"path", cfg.FilePath, "error", err)
|
||||
} else {
|
||||
fileHandler := slog.NewTextHandler(f, &slog.HandlerOptions{
|
||||
Level: fileLevel,
|
||||
})
|
||||
handlers = append(handlers, fileHandler)
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewContextHandler(&multiHandler{handlers: handlers})
|
||||
return slog.New(handler)
|
||||
}
|
||||
|
||||
// multiHandler fans out to multiple slog.Handler implementations.
|
||||
type multiHandler struct {
|
||||
handlers []slog.Handler
|
||||
}
|
||||
|
||||
func (m *multiHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
for _, h := range m.handlers {
|
||||
if h.Enabled(ctx, level) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *multiHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
for _, h := range m.handlers {
|
||||
if h.Enabled(ctx, r.Level) {
|
||||
if err := h.Handle(ctx, r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
clones := make([]slog.Handler, len(m.handlers))
|
||||
for i, h := range m.handlers {
|
||||
clones[i] = h.WithAttrs(attrs)
|
||||
}
|
||||
return &multiHandler{handlers: clones}
|
||||
}
|
||||
|
||||
func (m *multiHandler) WithGroup(name string) slog.Handler {
|
||||
clones := make([]slog.Handler, len(m.handlers))
|
||||
for i, h := range m.handlers {
|
||||
clones[i] = h.WithGroup(name)
|
||||
}
|
||||
return &multiHandler{handlers: clones}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
mygolog "github.com/dhao2001/mygo/internal/log"
|
||||
)
|
||||
|
||||
// RequestID returns a Gin middleware that ensures every request has a
|
||||
// request ID. If the client sends X-Request-ID, it is reused; otherwise a
|
||||
// new UUID v4 is generated. The ID is injected into both the Go
|
||||
// context.Context (for slog) and the Gin context (for handler access).
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
reqID := c.GetHeader("X-Request-ID")
|
||||
if reqID == "" {
|
||||
reqID = uuid.NewString()
|
||||
}
|
||||
|
||||
// Inject into Go context for slog.*Context calls.
|
||||
c.Request = c.Request.WithContext(mygolog.WithRequestID(c.Request.Context(), reqID))
|
||||
|
||||
// Also set in Gin context for direct access.
|
||||
c.Set("req_id", reqID)
|
||||
c.Header("X-Request-ID", reqID)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user