517 lines
11 KiB
Go
517 lines
11 KiB
Go
package logging
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/VexoraDevelopment/consolex/style"
|
|
"github.com/VexoraDevelopment/consolex/term"
|
|
)
|
|
|
|
type RecordField struct {
|
|
Key string
|
|
Value string
|
|
ValueOut string
|
|
ShowKey bool
|
|
Styled bool
|
|
Style style.Chalk
|
|
}
|
|
|
|
type LogRecord struct {
|
|
Raw string
|
|
Time string
|
|
Level string
|
|
Message string
|
|
Fields []RecordField
|
|
}
|
|
|
|
type Profile struct {
|
|
HideKeys map[string]bool
|
|
LevelLabels map[string]string
|
|
CompactMode bool
|
|
}
|
|
|
|
func DefaultProfile() Profile {
|
|
return Profile{
|
|
HideKeys: map[string]bool{
|
|
"time": true,
|
|
"level": true,
|
|
"msg": true,
|
|
"err": true,
|
|
"error": true,
|
|
"player": true,
|
|
"world": true,
|
|
},
|
|
LevelLabels: map[string]string{
|
|
"DEBUG": "DBG",
|
|
"INFO": "INF",
|
|
"WARN": "WRN",
|
|
"ERROR": "ERR",
|
|
},
|
|
CompactMode: true,
|
|
}
|
|
}
|
|
|
|
func normalizeProfile(p Profile) Profile {
|
|
d := DefaultProfile()
|
|
if p.HideKeys == nil {
|
|
p.HideKeys = d.HideKeys
|
|
}
|
|
if p.LevelLabels == nil {
|
|
p.LevelLabels = d.LevelLabels
|
|
}
|
|
if !p.CompactMode {
|
|
p.CompactMode = d.CompactMode
|
|
}
|
|
return p
|
|
}
|
|
|
|
type Processor interface {
|
|
Process(rec *LogRecord)
|
|
}
|
|
|
|
type ProcessorFunc func(rec *LogRecord)
|
|
|
|
func (f ProcessorFunc) Process(rec *LogRecord) {
|
|
f(rec)
|
|
}
|
|
|
|
type Renderer interface {
|
|
Render(rec *LogRecord) string
|
|
}
|
|
|
|
type RendererFunc func(rec *LogRecord) string
|
|
|
|
func (f RendererFunc) Render(rec *LogRecord) string {
|
|
return f(rec)
|
|
}
|
|
|
|
type FieldStyleProvider interface {
|
|
StyleField(key, value string) (style.Chalk, bool)
|
|
}
|
|
|
|
type FieldTransformer interface {
|
|
TransformField(key, value string) (string, bool)
|
|
}
|
|
|
|
type FieldStyleFunc func(key, value string) (style.Chalk, bool)
|
|
|
|
func (f FieldStyleFunc) StyleField(key, value string) (style.Chalk, bool) {
|
|
return f(key, value)
|
|
}
|
|
|
|
type FieldTransformFunc func(key, value string) (string, bool)
|
|
|
|
func (f FieldTransformFunc) TransformField(key, value string) (string, bool) {
|
|
return f(key, value)
|
|
}
|
|
|
|
type StaticFieldProvider map[string]style.Chalk
|
|
|
|
func (p StaticFieldProvider) StyleField(key, _ string) (style.Chalk, bool) {
|
|
st, ok := p[key]
|
|
return st, ok
|
|
}
|
|
|
|
type fieldTransformProcessor struct {
|
|
transformer FieldTransformer
|
|
}
|
|
|
|
func (p fieldTransformProcessor) Process(rec *LogRecord) {
|
|
if p.transformer == nil {
|
|
return
|
|
}
|
|
for i := range rec.Fields {
|
|
if out, ok := p.transformer.TransformField(rec.Fields[i].Key, rec.Fields[i].Value); ok {
|
|
rec.Fields[i].ValueOut = out
|
|
}
|
|
}
|
|
}
|
|
|
|
type fieldStyleProcessor struct {
|
|
provider FieldStyleProvider
|
|
}
|
|
|
|
func (p fieldStyleProcessor) Process(rec *LogRecord) {
|
|
if p.provider == nil {
|
|
return
|
|
}
|
|
for i := range rec.Fields {
|
|
value := rec.Fields[i].Value
|
|
if rec.Fields[i].ValueOut != "" {
|
|
value = rec.Fields[i].ValueOut
|
|
}
|
|
if st, ok := p.provider.StyleField(rec.Fields[i].Key, value); ok {
|
|
rec.Fields[i].Style = st
|
|
rec.Fields[i].Styled = true
|
|
}
|
|
}
|
|
}
|
|
|
|
type errorFieldProcessor struct {
|
|
errStyle style.Chalk
|
|
}
|
|
|
|
func (p errorFieldProcessor) Process(rec *LogRecord) {
|
|
for i := range rec.Fields {
|
|
if rec.Fields[i].Styled {
|
|
continue
|
|
}
|
|
if rec.Fields[i].Key == "err" || rec.Fields[i].Key == "error" {
|
|
rec.Fields[i].Style = p.errStyle
|
|
rec.Fields[i].Styled = true
|
|
}
|
|
}
|
|
}
|
|
|
|
type Pipeline struct {
|
|
processors []Processor
|
|
renderer Renderer
|
|
}
|
|
|
|
func NewPipeline(theme style.Theme, profile Profile, provider FieldStyleProvider, transformer FieldTransformer, extras []Processor, renderer Renderer) *Pipeline {
|
|
processors := []Processor{
|
|
fieldTransformProcessor{transformer: transformer},
|
|
fieldStyleProcessor{provider: provider},
|
|
errorFieldProcessor{errStyle: theme.ErrKey},
|
|
}
|
|
processors = append(processors, extras...)
|
|
if renderer == nil {
|
|
renderer = newDefaultRenderer(theme, profile)
|
|
}
|
|
return &Pipeline{processors: processors, renderer: renderer}
|
|
}
|
|
|
|
func (p *Pipeline) Colorize(line string) string {
|
|
rec := ParseTextLogLine(line)
|
|
for _, proc := range p.processors {
|
|
proc.Process(rec)
|
|
}
|
|
return p.renderer.Render(rec)
|
|
}
|
|
|
|
type defaultRenderer struct {
|
|
theme style.Theme
|
|
profile Profile
|
|
}
|
|
|
|
func newDefaultRenderer(theme style.Theme, profile Profile) Renderer {
|
|
return defaultRenderer{theme: theme, profile: normalizeProfile(profile)}
|
|
}
|
|
|
|
func (r defaultRenderer) Render(rec *LogRecord) string {
|
|
parts := make([]string, 0, 3+len(rec.Fields))
|
|
if rec.Time != "" {
|
|
parts = append(parts, r.theme.TimeValue.Dim().Wrap(rec.Time))
|
|
}
|
|
if rec.Level != "" {
|
|
parts = append(parts, r.levelBadge(rec.Level))
|
|
}
|
|
if rec.Message != "" {
|
|
parts = append(parts, rec.Message)
|
|
}
|
|
for _, f := range rec.Fields {
|
|
value := f.Value
|
|
if f.ValueOut != "" {
|
|
value = f.ValueOut
|
|
}
|
|
if f.Styled {
|
|
value = f.Style.Wrap(value)
|
|
}
|
|
showKey := f.ShowKey
|
|
if r.profile.CompactMode {
|
|
if hidden, ok := r.profile.HideKeys[f.Key]; ok && hidden {
|
|
showKey = false
|
|
}
|
|
}
|
|
if !showKey {
|
|
parts = append(parts, value)
|
|
continue
|
|
}
|
|
parts = append(parts, f.Key+"="+value)
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func (r defaultRenderer) levelBadge(level string) string {
|
|
lvl := strings.ToUpper(level)
|
|
label := lvl
|
|
if l, ok := r.profile.LevelLabels[lvl]; ok {
|
|
label = l
|
|
}
|
|
switch lvl {
|
|
case "DEBUG":
|
|
return r.theme.Debug.Wrap(label)
|
|
case "WARN":
|
|
return r.theme.Warn.Wrap(label)
|
|
case "ERROR":
|
|
return r.theme.Error.Wrap(label)
|
|
default:
|
|
return r.theme.Info.Wrap(label)
|
|
}
|
|
}
|
|
|
|
func ParseTextLogLine(line string) *LogRecord {
|
|
rec := &LogRecord{Raw: line, Fields: make([]RecordField, 0, 16)}
|
|
tokens := splitQuotedTokens(line)
|
|
for _, tok := range tokens {
|
|
i := strings.IndexByte(tok, '=')
|
|
if i <= 0 {
|
|
continue
|
|
}
|
|
key := tok[:i]
|
|
value := tok[i+1:]
|
|
switch key {
|
|
case "time":
|
|
rec.Time = value
|
|
case "level":
|
|
rec.Level = strings.Trim(value, "\"")
|
|
case "msg":
|
|
rec.Message = value
|
|
default:
|
|
rec.Fields = append(rec.Fields, RecordField{
|
|
Key: key,
|
|
Value: value,
|
|
ShowKey: true,
|
|
})
|
|
}
|
|
}
|
|
return rec
|
|
}
|
|
|
|
func splitQuotedTokens(s string) []string {
|
|
tokens := make([]string, 0, 16)
|
|
start := -1
|
|
inQuotes := false
|
|
escaped := false
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if start == -1 && c != ' ' {
|
|
start = i
|
|
}
|
|
if start == -1 {
|
|
continue
|
|
}
|
|
if inQuotes {
|
|
if escaped {
|
|
escaped = false
|
|
continue
|
|
}
|
|
if c == '\\' {
|
|
escaped = true
|
|
continue
|
|
}
|
|
if c == '"' {
|
|
inQuotes = false
|
|
}
|
|
continue
|
|
}
|
|
if c == '"' {
|
|
inQuotes = true
|
|
continue
|
|
}
|
|
if c == ' ' {
|
|
tokens = append(tokens, s[start:i])
|
|
start = -1
|
|
}
|
|
}
|
|
if start != -1 {
|
|
tokens = append(tokens, s[start:])
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
var (
|
|
stateMu sync.RWMutex
|
|
currentTheme = style.DefaultTheme()
|
|
currentProf = DefaultProfile()
|
|
pipeline = NewPipeline(currentTheme, currentProf, nil, nil, nil, nil)
|
|
)
|
|
|
|
type LoggerConfig struct {
|
|
LogFilePath string
|
|
ArchiveDir string
|
|
Level slog.Level
|
|
Theme style.Theme
|
|
Profile Profile
|
|
FieldProvider FieldStyleProvider
|
|
FieldTransform FieldTransformer
|
|
Processors []Processor
|
|
Renderer Renderer
|
|
}
|
|
|
|
func SetupDefaultSlog(cfg LoggerConfig) (*os.File, error) {
|
|
term.EnableConsoleANSI()
|
|
theme := cfg.Theme
|
|
if theme.TimeKey.Wrap("x") == "x" {
|
|
theme = style.DefaultTheme()
|
|
}
|
|
prof := normalizeProfile(cfg.Profile)
|
|
pl := NewPipeline(theme, prof, cfg.FieldProvider, cfg.FieldTransform, cfg.Processors, cfg.Renderer)
|
|
|
|
stateMu.Lock()
|
|
currentTheme = theme
|
|
currentProf = prof
|
|
pipeline = pl
|
|
stateMu.Unlock()
|
|
|
|
logPath := strings.TrimSpace(cfg.LogFilePath)
|
|
if logPath == "" {
|
|
logPath = "server.log"
|
|
}
|
|
archiveDir := strings.TrimSpace(cfg.ArchiveDir)
|
|
if archiveDir == "" {
|
|
archiveDir = "logs"
|
|
}
|
|
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create logs dir: %w", err)
|
|
}
|
|
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open %s: %w", logPath, err)
|
|
}
|
|
|
|
consoleOut := NewColorizingWriter(os.Stdout)
|
|
consoleHandler := slog.NewTextHandler(consoleOut, &slog.HandlerOptions{Level: cfg.Level})
|
|
fileHandler := slog.NewTextHandler(file, &slog.HandlerOptions{Level: cfg.Level})
|
|
slog.SetDefault(slog.New(fanoutHandler{handlers: []slog.Handler{consoleHandler, fileHandler}}))
|
|
return file, nil
|
|
}
|
|
|
|
func RotateAndCompressLog(srcPath, archiveDir string) error {
|
|
srcPath = strings.TrimSpace(srcPath)
|
|
if srcPath == "" {
|
|
srcPath = "server.log"
|
|
}
|
|
archiveDir = strings.TrimSpace(archiveDir)
|
|
if archiveDir == "" {
|
|
archiveDir = "logs"
|
|
}
|
|
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
info, err := os.Stat(srcPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if info.Size() == 0 {
|
|
return nil
|
|
}
|
|
ts := time.Now().Format("2006-01-02_15-04-05")
|
|
dst := filepath.Join(archiveDir, fmt.Sprintf("server_%s.log.gz", ts))
|
|
|
|
in, err := os.Open(srcPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func(in *os.File) {
|
|
_ = in.Close()
|
|
}(in)
|
|
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
gz := gzip.NewWriter(out)
|
|
|
|
_, copyErr := io.Copy(gz, in)
|
|
closeErr := gz.Close()
|
|
outCloseErr := out.Close()
|
|
if copyErr != nil {
|
|
return copyErr
|
|
}
|
|
if closeErr != nil {
|
|
return closeErr
|
|
}
|
|
if outCloseErr != nil {
|
|
return outCloseErr
|
|
}
|
|
return os.Truncate(srcPath, 0)
|
|
}
|
|
|
|
type fanoutHandler struct {
|
|
handlers []slog.Handler
|
|
}
|
|
|
|
func (h fanoutHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
|
for _, next := range h.handlers {
|
|
if next.Enabled(ctx, level) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (h fanoutHandler) Handle(ctx context.Context, rec slog.Record) error {
|
|
var firstErr error
|
|
for _, next := range h.handlers {
|
|
if err := next.Handle(ctx, rec); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
func (h fanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
out := make([]slog.Handler, 0, len(h.handlers))
|
|
for _, next := range h.handlers {
|
|
out = append(out, next.WithAttrs(attrs))
|
|
}
|
|
return fanoutHandler{handlers: out}
|
|
}
|
|
|
|
func (h fanoutHandler) WithGroup(name string) slog.Handler {
|
|
out := make([]slog.Handler, 0, len(h.handlers))
|
|
for _, next := range h.handlers {
|
|
out = append(out, next.WithGroup(name))
|
|
}
|
|
return fanoutHandler{handlers: out}
|
|
}
|
|
|
|
type ColorizingWriter struct {
|
|
dst io.Writer
|
|
buf []byte
|
|
}
|
|
|
|
func NewColorizingWriter(dst io.Writer) *ColorizingWriter {
|
|
return &ColorizingWriter{dst: dst}
|
|
}
|
|
|
|
func (w *ColorizingWriter) Write(p []byte) (int, error) {
|
|
w.buf = append(w.buf, p...)
|
|
for {
|
|
i := bytes.IndexByte(w.buf, '\n')
|
|
if i < 0 {
|
|
break
|
|
}
|
|
line := string(w.buf[:i])
|
|
w.buf = w.buf[i+1:]
|
|
if _, err := io.WriteString(w.dst, ColorizeLogLine(line)+"\n"); err != nil {
|
|
return len(p), err
|
|
}
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func ColorizeLogLine(line string) string {
|
|
stateMu.RLock()
|
|
pl := pipeline
|
|
stateMu.RUnlock()
|
|
if pl == nil {
|
|
return line
|
|
}
|
|
return pl.Colorize(line)
|
|
}
|