Persist claims, AP, teams and presets with session receipts and atomic request rollback. Read rewards and growth from GameData; replace fixed Fiend Hunt user state and preserve the account initialization contract.
181 lines
4.9 KiB
Go
181 lines
4.9 KiB
Go
// Package logging configures the server's structured console logging.
|
|
package logging
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
const LevelTrace slog.Level = -8
|
|
|
|
type ColorMode string
|
|
|
|
const (
|
|
ColorAuto ColorMode = "auto"
|
|
ColorAlways ColorMode = "always"
|
|
ColorNever ColorMode = "never"
|
|
)
|
|
|
|
type Options struct {
|
|
// Level defaults to INFO. A *slog.LevelVar supports changes at runtime.
|
|
Level slog.Leveler
|
|
// Color defaults to auto; always explicitly forces ANSI, including in pipes.
|
|
Color ColorMode
|
|
}
|
|
|
|
// OptionsFromEnv reads process-wide defaults for every server command.
|
|
func OptionsFromEnv() (Options, error) {
|
|
options := Options{Level: slog.LevelInfo, Color: ColorAuto}
|
|
if value := os.Getenv("BD2_LOG_LEVEL"); value != "" {
|
|
level, err := ParseLevel(value)
|
|
if err != nil {
|
|
return Options{}, err
|
|
}
|
|
options.Level = level
|
|
}
|
|
if value := os.Getenv("BD2_LOG_COLOR"); value != "" {
|
|
color, err := ParseColorMode(value)
|
|
if err != nil {
|
|
return Options{}, err
|
|
}
|
|
options.Color = color
|
|
}
|
|
return options, nil
|
|
}
|
|
|
|
func ParseLevel(value string) (slog.Level, error) {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "trace":
|
|
return LevelTrace, nil
|
|
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 0, fmt.Errorf("invalid log level %q (use trace, debug, info, warn, or error)", value)
|
|
}
|
|
}
|
|
|
|
func ParseColorMode(value string) (ColorMode, error) {
|
|
mode := ColorMode(strings.ToLower(strings.TrimSpace(value)))
|
|
if mode != ColorAuto && mode != ColorAlways && mode != ColorNever {
|
|
return "", fmt.Errorf("invalid log color %q (use auto, always, or never)", value)
|
|
}
|
|
return mode, nil
|
|
}
|
|
|
|
// Setup installs a logger as slog.Default, so existing slog callers use it too.
|
|
func Setup(writer io.Writer, options Options) (*slog.Logger, error) {
|
|
handler, err := NewHandler(writer, options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
logger := slog.New(handler)
|
|
slog.SetDefault(logger)
|
|
return logger, nil
|
|
}
|
|
|
|
// NewHandler retains slog's quoting, groups, LogValuer resolution and shared
|
|
// write lock. Only the built-in level label is colored; attributes stay intact.
|
|
func NewHandler(writer io.Writer, options Options) (slog.Handler, error) {
|
|
if writer == nil {
|
|
return nil, fmt.Errorf("log writer is nil")
|
|
}
|
|
mode := options.Color
|
|
if mode == "" {
|
|
mode = ColorAuto
|
|
}
|
|
if _, err := ParseColorMode(string(mode)); err != nil {
|
|
return nil, err
|
|
}
|
|
color := mode == ColorAlways
|
|
if mode == ColorAuto {
|
|
if file, ok := writer.(*os.File); ok && autoColorAllowed() {
|
|
color = terminalSupportsColor(file)
|
|
}
|
|
}
|
|
if color {
|
|
writer = levelColorWriter{writer}
|
|
}
|
|
return slog.NewTextHandler(writer, &slog.HandlerOptions{
|
|
Level: options.Level,
|
|
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
|
if len(groups) == 0 && attr.Key == slog.LevelKey {
|
|
if level, ok := attr.Value.Any().(slog.Level); ok && level == LevelTrace {
|
|
return slog.String(slog.LevelKey, "TRACE")
|
|
}
|
|
}
|
|
return attr
|
|
},
|
|
}), nil
|
|
}
|
|
|
|
func autoColorAllowed() bool {
|
|
_, noColor := os.LookupEnv("NO_COLOR")
|
|
return !noColor && os.Getenv("TERM") != "dumb"
|
|
}
|
|
|
|
type levelColorWriter struct{ io.Writer }
|
|
|
|
func (w levelColorWriter) Write(data []byte) (int, error) {
|
|
// TextHandler emits time, level, then msg. Search only before msg, so
|
|
// user attributes or messages containing "level=" cannot select a color.
|
|
end := bytes.Index(data, []byte(" msg="))
|
|
if end < 0 {
|
|
return w.Writer.Write(data)
|
|
}
|
|
start := bytes.Index(data[:end], []byte(" level="))
|
|
if start < 0 {
|
|
return w.Writer.Write(data)
|
|
}
|
|
start += len(" level=")
|
|
label := string(data[start:end])
|
|
var color string
|
|
switch {
|
|
case strings.HasPrefix(label, "TRACE"):
|
|
color = "\x1b[90m"
|
|
case strings.HasPrefix(label, "DEBUG"):
|
|
color = "\x1b[36m"
|
|
case strings.HasPrefix(label, "INFO"):
|
|
color = "\x1b[32m"
|
|
case strings.HasPrefix(label, "WARN"):
|
|
color = "\x1b[33m"
|
|
case strings.HasPrefix(label, "ERROR"):
|
|
color = "\x1b[31m"
|
|
default:
|
|
return w.Writer.Write(data)
|
|
}
|
|
output := make([]byte, 0, len(data)+len(color)+4)
|
|
output = append(output, data[:start]...)
|
|
output = append(output, color...)
|
|
output = append(output, data[start:end]...)
|
|
output = append(output, "\x1b[0m"...)
|
|
output = append(output, data[end:]...)
|
|
n, err := w.Writer.Write(output)
|
|
if err == nil && n != len(output) {
|
|
err = io.ErrShortWrite
|
|
}
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return len(data), nil
|
|
}
|
|
|
|
func Trace(message string, args ...any) { TraceContext(context.Background(), message, args...) }
|
|
func TraceContext(ctx context.Context, message string, args ...any) {
|
|
slog.Log(ctx, LevelTrace, message, args...)
|
|
}
|
|
func Debug(message string, args ...any) { slog.Debug(message, args...) }
|
|
func Info(message string, args ...any) { slog.Info(message, args...) }
|
|
func Warn(message string, args ...any) { slog.Warn(message, args...) }
|
|
func Error(message string, args ...any) { slog.Error(message, args...) }
|