v0.23.0 color, presets, certificates made at startup, cli.go, sink flush
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-04 03:44:12 -04:00
1 parent 15745682bc
commit bf410e1225
50 files changed
+2649 -426

No files matched your search

+86 -12
View File
@@ -18,8 +18,10 @@ import (
"github.com/lixenwraith/logwisp/internal/session"
"github.com/lixenwraith/logwisp/internal/sink"
"github.com/lixenwraith/color"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
"github.com/lixenwraith/terminal/inline"
"golang.org/x/term"
)
@@ -38,8 +40,9 @@ type ConsoleSink struct {
session *session.Session
// Configuration
config *config.ConsoleSinkOptions
escape bool
config *config.ConsoleSinkOptions
escape bool
painter *inline.Printer // paints level names; nil when color is off
// Application
input chan core.TransportEvent
@@ -63,8 +66,23 @@ const (
DefaultConsoleTarget = "stdout"
DefaultConsoleBufferSize = 1000
DefaultConsoleEscape = "auto"
DefaultConsoleColor = "auto"
)
// levelColors paints the first of a level's names in an entry, ANSI 16 so
// the terminal's theme picks the shades and a text console renders them.
// Names run longest first; cyan, not blue, stays readable on black.
var levelColors = map[string]struct {
names []string
style inline.Style
}{
"ERROR": {[]string{"ERROR", "FATAL", "ERR"}, inline.FgANSI(color.ANSIRed).Bold()},
"WARN": {[]string{"WARNING", "WARN"}, inline.FgANSI(color.ANSIYellow)},
"INFO": {[]string{"INFO", "INF"}, inline.FgANSI(color.ANSIGreen)},
"DEBUG": {[]string{"DEBUG", "DBG"}, inline.FgANSI(color.ANSICyan)},
"TRACE": {[]string{"TRACE"}, inline.FgANSI(color.ANSIBrightBlack)},
}
// NewConsoleSinkPlugin creates a console sink through plugin factory
func NewConsoleSinkPlugin(
id string,
@@ -72,7 +90,7 @@ func NewConsoleSinkPlugin(
logger *log.Logger,
proxy *session.Proxy,
) (sink.Sink, error) {
opts := &config.ConsoleSinkOptions{Target: DefaultConsoleTarget, Escape: DefaultConsoleEscape}
opts := &config.ConsoleSinkOptions{Target: DefaultConsoleTarget, Escape: DefaultConsoleEscape, Color: DefaultConsoleColor}
// Scan config map into struct
if err := config.Scan(configMap, opts); err != nil {
@@ -85,6 +103,9 @@ func NewConsoleSinkPlugin(
if err := lconfig.OneOf("auto", "always", "never")(opts.Escape); err != nil {
return nil, fmt.Errorf("escape: %w", err)
}
if err := lconfig.OneOf("auto", "always", "never")(opts.Color); err != nil {
return nil, fmt.Errorf("color: %w", err)
}
output := os.Stdout
if opts.Target == "stderr" {
@@ -95,17 +116,29 @@ func NewConsoleSinkPlugin(
opts.BufferSize = DefaultConsoleBufferSize
}
// inline decides auto: a terminal, NO_COLOR unset, TERM not dumb. Paint
// returns its text unchanged exactly when color is off.
printer := inline.New(output)
if opts.Color != "auto" {
printer.SetColor(opts.Color == "always")
}
var painter *inline.Printer
if printer.Paint("x", inline.Style{}) != "x" {
painter = printer
}
// Create and return plugin instance
cs := &ConsoleSink{
id: id,
proxy: proxy,
config: opts,
escape: opts.Escape == "always" || opts.Escape == "auto" && term.IsTerminal(int(output.Fd())),
input: make(chan core.TransportEvent, opts.BufferSize),
output: output,
done: make(chan struct{}),
exited: make(chan struct{}),
logger: logger,
id: id,
proxy: proxy,
config: opts,
escape: opts.Escape == "always" || opts.Escape == "auto" && term.IsTerminal(int(output.Fd())),
painter: painter,
input: make(chan core.TransportEvent, opts.BufferSize),
output: output,
done: make(chan struct{}),
exited: make(chan struct{}),
logger: logger,
}
cs.lastProcessed.Store(time.Time{})
@@ -124,6 +157,7 @@ func NewConsoleSinkPlugin(
"instance_id", id,
"target", opts.Target,
"escape", cs.escape,
"color", painter != nil,
)
return cs, nil
@@ -187,6 +221,7 @@ func (cs *ConsoleSink) GetStats() sink.SinkStats {
"target": cs.config.Target,
"buffer_size": cs.config.BufferSize,
"escape": cs.escape,
"color": cs.painter != nil,
},
}
}
@@ -224,6 +259,9 @@ func (cs *ConsoleSink) write(event core.TransportEvent) {
if cs.escape {
payload = escapeControls(payload)
}
if cs.painter != nil {
payload = paintLevel(cs.painter, payload, event.Entry.Level)
}
if _, err := cs.output.Write(payload); err != nil {
cs.logger.Error("msg", "Failed to write to console",
"component", "console_sink",
@@ -235,6 +273,42 @@ func (cs *ConsoleSink) write(event core.TransportEvent) {
cs.lastProcessed.Store(time.Now())
}
// paintLevel colors the first whole-word, case-insensitive occurrence of a
// name of level, after escaping: the escape sequences are the sink's own.
func paintLevel(p *inline.Printer, payload []byte, level string) []byte {
lc, ok := levelColors[level]
if !ok {
return payload
}
for _, name := range lc.names {
for i := 0; i+len(name) <= len(payload); i++ {
j := i + len(name)
if !nameAt(payload[i:j], name) || i > 0 && word(payload[i-1]) || j < len(payload) && word(payload[j]) {
continue
}
out := make([]byte, 0, len(payload)+16)
out = append(out, payload[:i]...)
out = append(out, p.Paint(string(payload[i:j]), lc.style)...)
return append(out, payload[j:]...)
}
}
return payload
}
// nameAt compares ASCII letters without case; name is upper case
func nameAt(b []byte, name string) bool {
for k := range len(name) {
if c := b[k]; c != name[k] && c != name[k]+'a'-'A' {
return false
}
}
return true
}
func word(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_'
}
// escapeControls writes what a terminal would act on or reorder (C0 and C1
// controls but tab, DEL, bidi controls, line separators, invalid UTF-8) as
// <hex>, keeping the final newline: a log line cannot drive the terminal.
+25 -1
View File
@@ -1,6 +1,11 @@
package console
import "testing"
import (
"io"
"testing"
"github.com/lixenwraith/terminal/inline"
)
// Everything a terminal would act on is escaped; tabs and the final newline,
// which it only lays out, are kept.
@@ -23,3 +28,22 @@ func TestEscapeControlsKeepsOnlyLayout(t *testing.T) {
}
}
}
// The entry's level is painted where its line first names it as a whole
// word, in any case, longest name first; other words and levels stay plain.
func TestPaintLevelColorsTheFirstWholeName(t *testing.T) {
p := inline.New(io.Discard)
p.SetColor(true)
red, yellow := "\x1b[0;1;31m", "\x1b[0;33m"
for _, c := range []struct{ in, level, want string }{
{"2026 ERROR boom ERROR\n", "ERROR", "2026 " + red + "ERROR\x1b[0m boom ERROR\n"},
{"[warning] disk\n", "WARN", "[" + yellow + "warning\x1b[0m] disk\n"},
{"terror_x ERR:1\n", "ERROR", "terror_x " + red + "ERR\x1b[0m:1\n"},
{"INFOS informal\n", "INFO", "INFOS informal\n"},
{"ERROR without level\n", "", "ERROR without level\n"},
} {
if got := string(paintLevel(p, []byte(c.in), c.level)); got != c.want {
t.Errorf("%q %s: got %q, want %q", c.in, c.level, got, c.want)
}
}
}