2026-05-09 13:36:41 +08:00

201 lines
5.0 KiB
Go

// Package logging provides admin-server-local structured stdout logging.
// It intentionally lives under server/admin/internal/platform because admin is a separate Go module.
package logging
import (
"context"
"fmt"
"io"
"log"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
"hyapp-admin-server/internal/appctx"
"github.com/gin-gonic/gin"
)
const (
defaultLevel = "info"
defaultFormat = "json"
defaultMaxPayloadBytes = 2048
redactedValue = "***REDACTED***"
)
type Config struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
IncludeRequestBody bool `yaml:"include_request_body"`
IncludeResponseBody bool `yaml:"include_response_body"`
MaxPayloadBytes int `yaml:"max_payload_bytes"`
}
var (
globalMu sync.RWMutex
globalLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo, ReplaceAttr: replaceAttr}))
)
func DefaultConfig() Config {
return Config{Level: defaultLevel, Format: defaultFormat, MaxPayloadBytes: defaultMaxPayloadBytes}
}
func Init(service string, env string, nodeID string, cfg Config) error {
level, err := parseLevel(cfg.Level)
if err != nil {
return err
}
format := strings.ToLower(strings.TrimSpace(cfg.Format))
if format == "" {
format = defaultFormat
}
var handler slog.Handler
options := &slog.HandlerOptions{Level: level, ReplaceAttr: replaceAttr}
switch format {
case "json":
handler = slog.NewJSONHandler(os.Stdout, options)
case "text":
handler = slog.NewTextHandler(os.Stdout, options)
default:
return fmt.Errorf("unsupported log format %q", cfg.Format)
}
logger := slog.New(handler).With(
"env", nonEmpty(env, "local"),
"service", nonEmpty(service, "admin-server"),
"node_id", nonEmpty(nodeID, service),
)
globalMu.Lock()
globalLogger = logger
globalMu.Unlock()
slog.SetDefault(logger)
log.SetFlags(0)
log.SetOutput(stdWriter{})
return nil
}
func StdWriter() io.Writer {
return stdWriter{}
}
func GinAccessLogger() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.URL.Path == "/healthz" || c.Request.URL.Path == "/readyz" {
c.Next()
return
}
startedAt := time.Now()
c.Next()
attrs := []slog.Attr{
slog.String("request_id", requestID(c)),
slog.String("app_code", appctx.FromContext(c.Request.Context())),
slog.String("method", c.Request.Method),
slog.String("path", c.Request.URL.Path),
slog.Int("status", c.Writer.Status()),
slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()),
slog.String("client_ip", c.ClientIP()),
}
if len(c.Errors) > 0 {
attrs = append(attrs, slog.String("error", c.Errors.String()))
}
switch {
case c.Writer.Status() >= http.StatusInternalServerError:
logAttrs(context.Background(), slog.LevelError, "http_access", attrs...)
case c.Writer.Status() >= http.StatusBadRequest:
logAttrs(context.Background(), slog.LevelWarn, "http_access", attrs...)
default:
logAttrs(context.Background(), slog.LevelInfo, "http_access", attrs...)
}
}
}
type stdWriter struct{}
func (stdWriter) Write(data []byte) (int, error) {
line := strings.TrimSpace(string(data))
if line != "" {
logAttrs(context.Background(), slog.LevelInfo, "stdlog", slog.String("message", redactLine(line)))
}
return len(data), nil
}
func logAttrs(ctx context.Context, level slog.Level, msg string, attrs ...slog.Attr) {
globalMu.RLock()
logger := globalLogger
globalMu.RUnlock()
logger.LogAttrs(ctx, level, msg, attrs...)
}
func requestID(c *gin.Context) string {
if value, ok := c.Get("requestID"); ok {
if requestID, ok := value.(string); ok {
return requestID
}
}
return c.GetHeader("X-Request-ID")
}
func parseLevel(value string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug":
return slog.LevelDebug, nil
case "", "info":
return slog.LevelInfo, nil
case "warn", "warning":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return slog.LevelInfo, fmt.Errorf("unsupported log level %q", value)
}
}
func replaceAttr(_ []string, attr slog.Attr) slog.Attr {
if isSensitiveKey(attr.Key) {
return slog.String(attr.Key, redactedValue)
}
return attr
}
func isSensitiveKey(key string) bool {
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
for _, part := range []string{"password", "credential", "secret", "token", "user_sig", "usersig", "authorization", "signature", "private_key", "receipt"} {
if strings.Contains(normalized, part) {
return true
}
}
return false
}
func redactLine(line string) string {
fields := strings.Fields(line)
changed := false
for index, field := range fields {
separator := strings.IndexAny(field, "=:")
if separator <= 0 {
continue
}
if isSensitiveKey(strings.Trim(field[:separator], "\"'")) {
fields[index] = field[:separator+1] + redactedValue
changed = true
}
}
if !changed {
return line
}
return strings.Join(fields, " ")
}
func nonEmpty(value string, fallback string) string {
value = strings.TrimSpace(value)
if value == "" {
return fallback
}
return value
}