360 lines
9.1 KiB
Go
360 lines
9.1 KiB
Go
package console
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/lixenwraith/logwisp/internal/config"
|
|
"github.com/lixenwraith/logwisp/internal/core"
|
|
"github.com/lixenwraith/logwisp/internal/plugin"
|
|
"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"
|
|
)
|
|
|
|
// init registers the component in plugin factory
|
|
func init() {
|
|
if err := plugin.RegisterSink("console", NewConsoleSinkPlugin); err != nil {
|
|
panic(fmt.Sprintf("failed to register console sink: %v", err))
|
|
}
|
|
}
|
|
|
|
// ConsoleSink writes formatted entries to stdout or stderr
|
|
type ConsoleSink struct {
|
|
// Plugin identity and session management
|
|
id string
|
|
proxy *session.Proxy
|
|
session *session.Session
|
|
|
|
// Configuration
|
|
config *config.ConsoleSinkOptions
|
|
escape bool
|
|
painter *inline.Printer // paints level names; nil when color is off
|
|
|
|
// Application
|
|
input chan core.TransportEvent
|
|
output io.Writer
|
|
logger *log.Logger // application logger
|
|
|
|
// Runtime
|
|
done chan struct{}
|
|
exited chan struct{}
|
|
started atomic.Bool
|
|
stopOnce sync.Once
|
|
startTime time.Time
|
|
|
|
// Statistics
|
|
totalProcessed atomic.Uint64
|
|
lastProcessed atomic.Value // time.Time
|
|
}
|
|
|
|
const (
|
|
// Defaults
|
|
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,
|
|
configMap map[string]any,
|
|
logger *log.Logger,
|
|
proxy *session.Proxy,
|
|
) (sink.Sink, error) {
|
|
opts := &config.ConsoleSinkOptions{Target: DefaultConsoleTarget, Escape: DefaultConsoleEscape, Color: DefaultConsoleColor}
|
|
|
|
// Scan config map into struct
|
|
if err := config.Scan(configMap, opts); err != nil {
|
|
return nil, fmt.Errorf("failed to parse config: %w", err)
|
|
}
|
|
|
|
if err := lconfig.OneOf("stdout", "stderr")(opts.Target); err != nil {
|
|
return nil, fmt.Errorf("target: %w", err)
|
|
}
|
|
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" {
|
|
output = os.Stderr
|
|
}
|
|
|
|
if opts.BufferSize <= 0 {
|
|
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())),
|
|
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{})
|
|
|
|
// Create session for output
|
|
cs.session = proxy.CreateSession(
|
|
fmt.Sprintf("console:%s", opts.Target),
|
|
map[string]any{
|
|
"instance_id": id,
|
|
"type": "console",
|
|
"target": opts.Target,
|
|
},
|
|
)
|
|
|
|
cs.logger.Info("msg", "Console sink initialized",
|
|
"component", "console_sink",
|
|
"instance_id", id,
|
|
"target", opts.Target,
|
|
"escape", cs.escape,
|
|
"color", painter != nil,
|
|
)
|
|
|
|
return cs, nil
|
|
}
|
|
|
|
// Capabilities returns supported capabilities
|
|
func (cs *ConsoleSink) Capabilities() []core.Capability {
|
|
return []core.Capability{
|
|
core.CapSessionAware, // Single output session
|
|
core.CapBackpressure, // a slow reader slows the pipeline, as with any filter
|
|
}
|
|
}
|
|
|
|
// Input returns the channel for sending transport events
|
|
func (cs *ConsoleSink) Input() chan<- core.TransportEvent {
|
|
return cs.input
|
|
}
|
|
|
|
// Start begins the processing loop
|
|
func (cs *ConsoleSink) Start(ctx context.Context) error {
|
|
cs.startTime = time.Now()
|
|
cs.started.Store(true)
|
|
go cs.processLoop(ctx)
|
|
cs.logger.Info("msg", "Console sink started",
|
|
"component", "console_sink",
|
|
"target", cs.config.Target)
|
|
return nil
|
|
}
|
|
|
|
// Stop writes what is queued, then returns
|
|
func (cs *ConsoleSink) Stop() {
|
|
cs.logger.Info("msg", "Stopping console sink", "target", cs.config.Target)
|
|
|
|
// Remove session
|
|
if cs.session != nil {
|
|
cs.proxy.RemoveSession(cs.session.ID)
|
|
}
|
|
|
|
cs.stopOnce.Do(func() { close(cs.done) })
|
|
if cs.started.Load() {
|
|
<-cs.exited
|
|
}
|
|
|
|
cs.logger.Info("msg", "Console sink stopped",
|
|
"instance_id", cs.id,
|
|
"target", cs.config.Target,
|
|
)
|
|
}
|
|
|
|
// GetStats returns sink statistics
|
|
func (cs *ConsoleSink) GetStats() sink.SinkStats {
|
|
lastProc, _ := cs.lastProcessed.Load().(time.Time)
|
|
|
|
return sink.SinkStats{
|
|
ID: cs.id,
|
|
Type: "console",
|
|
TotalProcessed: cs.totalProcessed.Load(),
|
|
StartTime: cs.startTime,
|
|
LastProcessed: lastProc,
|
|
Details: map[string]any{
|
|
"target": cs.config.Target,
|
|
"buffer_size": cs.config.BufferSize,
|
|
"escape": cs.escape,
|
|
"color": cs.painter != nil,
|
|
},
|
|
}
|
|
}
|
|
|
|
// processLoop writes transport events until stopped, then what is queued
|
|
func (cs *ConsoleSink) processLoop(ctx context.Context) {
|
|
defer close(cs.exited)
|
|
for {
|
|
select {
|
|
case event := <-cs.input:
|
|
cs.write(event)
|
|
case <-ctx.Done():
|
|
cs.drain()
|
|
return
|
|
case <-cs.done:
|
|
cs.drain()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (cs *ConsoleSink) drain() {
|
|
for {
|
|
select {
|
|
case event := <-cs.input:
|
|
cs.write(event)
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (cs *ConsoleSink) write(event core.TransportEvent) {
|
|
payload := event.Payload
|
|
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",
|
|
"target", cs.config.Target,
|
|
"error", err)
|
|
return
|
|
}
|
|
cs.totalProcessed.Add(1)
|
|
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.
|
|
// Other invisible characters stay: emoji and Indic scripts need ZWJ.
|
|
func escapeControls(p []byte) []byte {
|
|
body, newline := bytes.CutSuffix(p, []byte{'\n'})
|
|
if printableASCII(body) {
|
|
return p
|
|
}
|
|
out := make([]byte, 0, len(p)+16)
|
|
for len(body) > 0 {
|
|
r, n := utf8.DecodeRune(body)
|
|
if !terminalControl(r, n) {
|
|
out = append(out, body[:n]...)
|
|
} else {
|
|
out = append(out, '<')
|
|
out = hex.AppendEncode(out, body[:n])
|
|
out = append(out, '>')
|
|
}
|
|
body = body[n:]
|
|
}
|
|
if newline {
|
|
out = append(out, '\n')
|
|
}
|
|
return out
|
|
}
|
|
|
|
func terminalControl(r rune, size int) bool {
|
|
switch {
|
|
case r == utf8.RuneError && size == 1:
|
|
return true
|
|
case r < 0x20:
|
|
return r != '\t'
|
|
case r >= 0x7f && r <= 0x9f, r == 0x061c, r == 0x200e, r == 0x200f,
|
|
r >= 0x2028 && r <= 0x202e, r >= 0x2066 && r <= 0x2069:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func printableASCII(b []byte) bool {
|
|
for _, c := range b {
|
|
if (c < 0x20 || c > 0x7e) && c != '\t' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|