1 Commits
v1.0.1 ... main

Author SHA1 Message Date
d3fef656ed chore: update console logging docs and pipeline behavior 2026-03-04 22:21:59 +03:00
4 changed files with 250 additions and 3 deletions

View File

@@ -32,6 +32,16 @@ logFile, err := consolex.SetupDefaultSlog(consolex.LoggerConfig{
ArchiveDir: "logs", ArchiveDir: "logs",
Level: slog.LevelDebug, Level: slog.LevelDebug,
Theme: consolex.NordTheme(), Theme: consolex.NordTheme(),
Dedupe: consolex.DedupeConfig{
Enabled: true,
Remap: []consolex.LevelRemapRule{
{
From: "INFO",
To: "DEBUG",
Contains: []string{"conn write packet failed", "context canceled"},
},
},
},
FieldProvider: consolex.StaticFieldProvider{ FieldProvider: consolex.StaticFieldProvider{
"proto_id": consolex.New().White().BgBlue().Bold(), "proto_id": consolex.New().White().BgBlue().Bold(),
"raddr": consolex.New().Black().BgYellow(), "raddr": consolex.New().Black().BgYellow(),
@@ -58,6 +68,40 @@ logFile, err := consolex.SetupDefaultSlog(consolex.LoggerConfig{
} }
``` ```
## Dedup/Aggregation (`xN`)
Enable duplicate line aggregation in `LoggerConfig.Dedupe`:
```go
cfg := consolex.LoggerConfig{
Level: slog.LevelDebug,
Dedupe: consolex.DedupeConfig{
Enabled: true,
Window: time.Second, // default: 1s
},
}
```
Repeated identical lines are collapsed and emitted once with:
- `repeat="xN"`
Example:
```text
time=... level=INFO msg="conn write packet failed" packet=*packet.UpdateBlock err="context canceled" repeat="x37"
```
You can also remap levels by rules:
```go
Dedupe: consolex.DedupeConfig{
Enabled: true,
Remap: []consolex.LevelRemapRule{
{From: "INFO", To: "DEBUG", Contains: []string{"context canceled"}},
},
},
```
## Chalk-like style API ## Chalk-like style API
```go ```go

2
api.go
View File

@@ -19,6 +19,8 @@ func NordTheme() Theme { return style.NordTheme() }
func SunsetTheme() Theme { return style.SunsetTheme() } func SunsetTheme() Theme { return style.SunsetTheme() }
type LoggerConfig = logging.LoggerConfig type LoggerConfig = logging.LoggerConfig
type DedupeConfig = logging.DedupeConfig
type LevelRemapRule = logging.LevelRemapRule
type Profile = logging.Profile type Profile = logging.Profile
type LogRecord = logging.LogRecord type LogRecord = logging.LogRecord
type RecordField = logging.RecordField type RecordField = logging.RecordField

View File

@@ -17,6 +17,16 @@ func main() {
Level: slog.LevelDebug, Level: slog.LevelDebug,
Theme: consolex.DefaultTheme(), Theme: consolex.DefaultTheme(),
Profile: profile, Profile: profile,
Dedupe: consolex.DedupeConfig{
Enabled: true,
Remap: []consolex.LevelRemapRule{
{
From: "INFO",
To: "DEBUG",
Contains: []string{"conn write packet failed", "context canceled"},
},
},
},
Processors: []consolex.Processor{ Processors: []consolex.Processor{
consolex.ProcessorFunc(func(rec *consolex.LogRecord) { consolex.ProcessorFunc(func(rec *consolex.LogRecord) {
// Example: force showing key for world pointer fields in compact layout. // Example: force showing key for world pointer fields in compact layout.
@@ -44,4 +54,7 @@ func main() {
defer logFile.Close() defer logFile.Close()
slog.Info("world register", "name", "hub_snow", "ptr", "0xc004ba0ea0") slog.Info("world register", "name", "hub_snow", "ptr", "0xc004ba0ea0")
for i := 0; i < 10; i++ {
slog.Info("conn write packet failed", "packet", "*packet.UpdateBlock", "err", "context canceled")
}
} }

View File

@@ -9,6 +9,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -347,6 +348,20 @@ type LoggerConfig struct {
FieldTransform FieldTransformer FieldTransform FieldTransformer
Processors []Processor Processors []Processor
Renderer Renderer Renderer Renderer
Dedupe DedupeConfig
}
type DedupeConfig struct {
Enabled bool
Window time.Duration
KeyFunc func(rec *LogRecord) string
Remap []LevelRemapRule
}
type LevelRemapRule struct {
From string
To string
Contains []string
} }
func SetupDefaultSlog(cfg LoggerConfig) (*os.File, error) { func SetupDefaultSlog(cfg LoggerConfig) (*os.File, error) {
@@ -380,9 +395,19 @@ func SetupDefaultSlog(cfg LoggerConfig) (*os.File, error) {
return nil, fmt.Errorf("open %s: %w", logPath, err) return nil, fmt.Errorf("open %s: %w", logPath, err)
} }
consoleOut := NewColorizingWriter(os.Stdout) consoleSink := io.Writer(NewColorizingWriter(os.Stdout))
consoleHandler := slog.NewTextHandler(consoleOut, &slog.HandlerOptions{Level: cfg.Level}) fileSink := io.Writer(file)
fileHandler := slog.NewTextHandler(file, &slog.HandlerOptions{Level: cfg.Level}) if cfg.Dedupe.Enabled {
window := cfg.Dedupe.Window
if window <= 0 {
window = time.Second
}
consoleSink = NewAggregateLineWriter(consoleSink, window, cfg.Dedupe.KeyFunc, cfg.Dedupe.Remap)
fileSink = NewAggregateLineWriter(fileSink, window, cfg.Dedupe.KeyFunc, cfg.Dedupe.Remap)
}
consoleHandler := slog.NewTextHandler(consoleSink, &slog.HandlerOptions{Level: cfg.Level})
fileHandler := slog.NewTextHandler(fileSink, &slog.HandlerOptions{Level: cfg.Level})
slog.SetDefault(slog.New(fanoutHandler{handlers: []slog.Handler{consoleHandler, fileHandler}})) slog.SetDefault(slog.New(fanoutHandler{handlers: []slog.Handler{consoleHandler, fileHandler}}))
return file, nil return file, nil
} }
@@ -514,3 +539,166 @@ func ColorizeLogLine(line string) string {
} }
return pl.Colorize(line) return pl.Colorize(line)
} }
type aggregateEntry struct {
key string
line string
count int
}
type AggregateLineWriter struct {
dst io.Writer
window time.Duration
keyFn func(*LogRecord) string
remap []LevelRemapRule
mu sync.Mutex
buf []byte
timer *time.Timer
cur *aggregateEntry
}
func NewAggregateLineWriter(dst io.Writer, window time.Duration, keyFn func(*LogRecord) string, remap []LevelRemapRule) *AggregateLineWriter {
if window <= 0 {
window = time.Second
}
return &AggregateLineWriter{
dst: dst,
window: window,
keyFn: keyFn,
remap: remap,
}
}
func (w *AggregateLineWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
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:]
w.ingestLocked(line)
}
return len(p), nil
}
func (w *AggregateLineWriter) ingestLocked(line string) {
rec := ParseTextLogLine(line)
applyLevelRemap(rec, w.remap)
rendered := renderTextRecord(rec)
key := rendered
if w.keyFn != nil {
key = w.keyFn(rec)
}
if key == "" {
key = rendered
}
if w.cur != nil && w.cur.key == key {
w.cur.count++
w.resetTimerLocked()
return
}
_ = w.flushLocked()
w.cur = &aggregateEntry{
key: key,
line: rendered,
count: 1,
}
w.resetTimerLocked()
}
func (w *AggregateLineWriter) resetTimerLocked() {
if w.timer == nil {
w.timer = time.AfterFunc(w.window, w.onTimer)
return
}
w.timer.Reset(w.window)
}
func (w *AggregateLineWriter) onTimer() {
w.mu.Lock()
defer w.mu.Unlock()
_ = w.flushLocked()
}
func (w *AggregateLineWriter) flushLocked() error {
if w.cur == nil {
return nil
}
line := w.cur.line
if w.cur.count > 1 {
line = line + " repeat=" + strconv.Quote("x"+strconv.Itoa(w.cur.count))
}
w.cur = nil
_, err := io.WriteString(w.dst, line+"\n")
return err
}
func applyLevelRemap(rec *LogRecord, rules []LevelRemapRule) {
if rec == nil || len(rules) == 0 {
return
}
for _, rule := range rules {
if !matchesLevelRule(rec, rule) {
continue
}
to := strings.TrimSpace(rule.To)
if to != "" {
rec.Level = strings.ToUpper(to)
}
return
}
}
func matchesLevelRule(rec *LogRecord, rule LevelRemapRule) bool {
from := strings.TrimSpace(rule.From)
if from != "" && !strings.EqualFold(strings.TrimSpace(rec.Level), from) {
return false
}
if len(rule.Contains) == 0 {
return true
}
hay := strings.ToLower(rec.Raw)
for _, needle := range rule.Contains {
n := strings.ToLower(strings.TrimSpace(needle))
if n == "" {
continue
}
if !strings.Contains(hay, n) {
return false
}
}
return true
}
func renderTextRecord(rec *LogRecord) string {
if rec == nil {
return ""
}
parts := make([]string, 0, 3+len(rec.Fields))
if rec.Time != "" {
parts = append(parts, "time="+rec.Time)
}
if rec.Level != "" {
parts = append(parts, "level="+strings.ToUpper(rec.Level))
}
if rec.Message != "" {
parts = append(parts, "msg="+rec.Message)
}
for _, f := range rec.Fields {
value := f.Value
if f.ValueOut != "" {
value = f.ValueOut
}
parts = append(parts, f.Key+"="+value)
}
return strings.Join(parts, " ")
}