first commit
This commit is contained in:
516
logging/logging.go
Normal file
516
logging/logging.go
Normal file
@@ -0,0 +1,516 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user