// 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 }