From 53bd473861e6c622798f8d7e14da79511ad5886b Mon Sep 17 00:00:00 2001 From: Huxley Date: Sat, 4 Jul 2026 16:24:37 +0800 Subject: [PATCH] Add structured logging with request ID middleware --- internal/log/context.go | 47 ++++++++++++++++++ internal/log/logger.go | 81 ++++++++++++++++++++++++++++++++ internal/middleware/requestid.go | 30 ++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 internal/log/context.go create mode 100644 internal/log/logger.go create mode 100644 internal/middleware/requestid.go diff --git a/internal/log/context.go b/internal/log/context.go new file mode 100644 index 0000000..99f6082 --- /dev/null +++ b/internal/log/context.go @@ -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) +} diff --git a/internal/log/logger.go b/internal/log/logger.go new file mode 100644 index 0000000..dfed508 --- /dev/null +++ b/internal/log/logger.go @@ -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} +} diff --git a/internal/middleware/requestid.go b/internal/middleware/requestid.go new file mode 100644 index 0000000..2507291 --- /dev/null +++ b/internal/middleware/requestid.go @@ -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() + } +}