201 lines
5.0 KiB
Go
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
|
|
}
|