v0.22.1 cli flag and behavior, and test output improvement

This commit is contained in:
lixen committed 2026-10-03 21:57:41 -04:00
1 parent 71d1b82f83
commit 4eba6fc834
89 files changed
+1531 -661

No files matched your search

+3 -3
View File
@@ -19,9 +19,9 @@ import (
"sync/atomic"
"time"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/tlsx"
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/tlsx"
"github.com/lixenwraith/log"
)
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"testing"
"logwisp/internal/config"
"logwisp/internal/tlsx"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/tlsx"
)
// peerState fakes a completed handshake. Only the leaf's identity fields are
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"strings"
"testing"
"logwisp/internal/chain"
"logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/auth"
)
+4 -4
View File
@@ -22,10 +22,10 @@ import (
"sync/atomic"
"time"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/tlsx"
"logwisp/internal/tokenbucket"
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/tlsx"
"github.com/lixenwraith/logwisp/internal/tokenbucket"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/toml"
+3 -3
View File
@@ -21,9 +21,9 @@ import (
"testing"
"time"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/auth"
)
+2 -1
View File
@@ -3,9 +3,10 @@ package chain
import (
"encoding/json"
"fmt"
"logwisp/internal/core"
"math/rand/v2"
"time"
"github.com/lixenwraith/logwisp/internal/core"
)
// ProtocolVersion is declared in the hello preamble
+1
View File
@@ -244,6 +244,7 @@ type NullSinkOptions struct{}
type ConsoleSinkOptions struct {
Target string `toml:"target"` // "stdout", "stderr"
BufferSize int64 `toml:"buffer_size"`
Escape string `toml:"escape"` // control characters as <hex>: "auto" (on a terminal), "always", "never"
}
// FileSinkOptions defines settings for a file-based sink
+23 -49
View File
@@ -3,11 +3,12 @@ package config
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/core"
lconfig "github.com/lixenwraith/config"
)
@@ -44,6 +45,11 @@ func Load(args []string) (*Manager, error) {
}
m := &Manager{path: configPath, explicit: isExplicit, specs: specs}
initial := defaults()
if _, err := os.Stat(configPath); errors.Is(err, fs.ErrNotExist) {
// Without a file lw is a command-line tool: warnings and errors only
initial.Logging.Level = "warn"
initial.StatusReporter = false
}
cfg, err := lconfig.NewBuilder().
WithTarget(initial).
WithEnvPrefix("LOGWISP_").
@@ -134,20 +140,13 @@ func (m *Manager) Watch() <-chan string {
func (m *Manager) Close() { m.config.StopAutoUpdate() }
// defaults provides the default configuration values for the application
// defaults provides the default configuration values for the application.
// Data goes to stdout, so lw's own log goes to stderr.
func defaults() *Config {
return &Config{
// Top-level flag defaults
ShowVersion: false,
Quiet: false,
// Runtime behavior defaults
StatusReporter: true,
ConfigAutoReload: false,
// Existing defaults
StatusReporter: true,
Logging: &LogConfig{
Output: "stdout",
Output: "stderr",
Level: "info",
Format: "txt",
File: &LogFileConfig{
@@ -158,45 +157,20 @@ func defaults() *Config {
RetentionHours: 168, // 7 days
},
Console: &LogConsoleConfig{
Target: "stdout",
},
},
Pipelines: []PipelineConfig{
{
Name: "default_pipeline",
Flow: &FlowConfig{
RateLimit: &RateLimitConfig{
Rate: 5,
Burst: 10,
Policy: "drop",
MaxEntrySizeBytes: 65536,
},
Format: &FormatConfig{
Type: "json",
SanitizerPolicy: "json",
},
},
PluginSources: []PluginSourceConfig{
{
ID: "default_source",
Type: "random",
Config: map[string]any{
"special": true,
},
},
},
PluginSinks: []PluginSinkConfig{
{
ID: "default_sink",
Type: "console",
Config: map[string]any{
"target": "stdout",
"buffer_size": 100,
},
},
},
Target: "stderr",
},
},
Pipelines: []PipelineConfig{pipeDefault("pipe")},
}
}
// pipeDefault is a filter in the Unix sense: stdin to stdout, line for line
func pipeDefault(name string) PipelineConfig {
return PipelineConfig{
Name: name,
Flow: &FlowConfig{Format: &FormatConfig{Type: "raw"}},
PluginSources: []PluginSourceConfig{{ID: "stdin", Type: "console", Config: map[string]any{}}},
PluginSinks: []PluginSinkConfig{{ID: "stdout", Type: "console", Config: map[string]any{}}},
}
}
+36 -3
View File
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/testutil"
)
func TestResolveConfigArguments(t *testing.T) {
@@ -56,9 +56,9 @@ func TestLoadPrecedenceAndDetachedSnapshots(t *testing.T) {
t.Fatalf("snapshot: %+v %v", first, err)
}
first.Logging.Level = "mutated"
first.Pipelines[0].PluginSources[0].Config["special"] = false
first.Pipelines[0].PluginSources[0].Config["buffer_size"] = 1
second, err := m.Snapshot()
if err != nil || second.Logging.Level != "warn" || second.Pipelines[0].PluginSources[0].Config["special"] != true {
if _, aliased := second.Pipelines[0].PluginSources[0].Config["buffer_size"]; err != nil || second.Logging.Level != "warn" || aliased {
t.Fatalf("snapshot aliases another result: %+v %v", second, err)
}
env, err := Load([]string{"-c=selected.toml"})
@@ -268,3 +268,36 @@ port = 9000
}
}
}
// A file makes lw a service, logging at info with the status reporter; without
// one it is a command-line tool that reports warnings and errors only.
func TestNoConfigFileMeansQuietLogging(t *testing.T) {
isolateConfig(t)
for _, c := range []struct {
file, level string
reporter bool
}{{"", "warn", false}, {"[logging]\noutput = \"stderr\"\n", "info", true}} {
if c.file != "" {
testutil.WriteFile(t, "logwisp.toml", c.file)
}
m, err := Load(nil)
if err != nil {
t.Fatal(err)
}
cfg, err := m.Snapshot()
m.Close()
if err != nil || cfg.Logging.Level != c.level || cfg.StatusReporter != c.reporter || cfg.Logging.Output != "stderr" {
t.Fatalf("file %q: %+v %+v %v", c.file, cfg, cfg.Logging, err)
}
}
t.Setenv("LOGWISP_LOGGING_LEVEL", "debug")
os.Remove("logwisp.toml")
m, err := Load(nil)
if err != nil {
t.Fatal(err)
}
defer m.Close()
if cfg, err := m.Snapshot(); err != nil || cfg.Logging.Level != "debug" {
t.Fatalf("environment over the file-less default: %+v %v", cfg, err)
}
}
+11 -1
View File
@@ -131,7 +131,8 @@ func envPipelineSpecs() []pipelineSpec {
}
// buildPipelines turns specs, in order, into pipelines; specs before the first
// --pipeline go to one named "cli". Every call returns fresh maps.
// --pipeline go to one named "cli". A pipeline without a source reads stdin,
// one without a sink writes stdout. Every call returns fresh maps.
func buildPipelines(specs []pipelineSpec) ([]PipelineConfig, error) {
var pipelines []PipelineConfig
var seen map[*specKind]bool
@@ -160,6 +161,15 @@ func buildPipelines(specs []pipelineSpec) ([]PipelineConfig, error) {
return nil, fmt.Errorf("%s %s: %w", s.name, s.value, err)
}
}
for i := range pipelines {
pipe := pipeDefault(pipelines[i].Name)
if len(pipelines[i].PluginSources) == 0 {
pipelines[i].PluginSources = pipe.PluginSources
}
if len(pipelines[i].PluginSinks) == 0 {
pipelines[i].PluginSinks = pipe.PluginSinks
}
}
return pipelines, nil
}
+20 -4
View File
@@ -4,9 +4,10 @@ import (
"errors"
"os"
"reflect"
"strings"
"testing"
"logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/testutil"
)
func loadPipelines(t *testing.T, args ...string) []PipelineConfig {
@@ -160,6 +161,21 @@ func TestSpecPipelinesReplaceFilePipelines(t *testing.T) {
}
}
// A spec pipeline without a source reads stdin and one without a sink writes
// stdout, as a filter does; two pipelines cannot both read stdin.
func TestSpecPipelinesDefaultToStdio(t *testing.T) {
isolateConfig(t)
got := loadPipelines(t, "--filter", "include,patterns=ERROR")
if p := got[0]; len(p.PluginSources) != 1 || p.PluginSources[0].Type != "console" || p.PluginSources[0].ID != "stdin" ||
len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "stdout" {
t.Fatalf("pipeline: %+v", p)
}
_, err := Load([]string{"--pipeline", "a", "--sink", "null", "--pipeline", "b", "--sink", "null"})
if err == nil || !strings.Contains(err.Error(), `console source already reads stdin in pipeline "a"`) {
t.Fatalf("second stdin reader: %v", err)
}
}
// Without a file a reload still rebuilds; once the discovered default
// appears it is read, and its pipelines still yield to the specs. Its later
// removal fails the reload instead of keeping the removed file's values.
@@ -171,13 +187,13 @@ func TestReloadKeepsSpecPipelines(t *testing.T) {
}
defer m.Close()
first, err := m.Reload()
if err != nil || !first.StatusReporter {
if err != nil || first.StatusReporter {
t.Fatalf("reload without a file: %+v %v", first, err)
}
first.Pipelines[0].PluginSources[0].Config["special"] = "mutated"
testutil.WriteFile(t, "logwisp.toml", "status_reporter = false\n[[pipelines]]\nname = \"file\"\n")
testutil.WriteFile(t, "logwisp.toml", "status_reporter = true\n[[pipelines]]\nname = \"file\"\n")
next, err := m.Reload()
if err != nil || next.StatusReporter || len(next.Pipelines) != 1 || next.Pipelines[0].Name != "cli" ||
if err != nil || !next.StatusReporter || len(next.Pipelines) != 1 || next.Pipelines[0].Name != "cli" ||
next.Pipelines[0].PluginSources[0].Config["special"] != "true" {
t.Fatalf("reload: %+v %v", next, err)
}
+1 -1
View File
@@ -3,7 +3,7 @@ package config
import (
"testing"
"logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/testutil"
)
func isolateConfig(t *testing.T) {
+10
View File
@@ -38,6 +38,7 @@ func ValidateConfig(cfg *Config) error {
return fmt.Errorf("logging: %w", err)
}
stdinReader := ""
for i, p := range cfg.Pipelines {
if err := lconfig.NonEmpty(p.Name); err != nil {
return fmt.Errorf("pipeline[%d].name: %w", i, err)
@@ -48,6 +49,15 @@ func ValidateConfig(cfg *Config) error {
if len(p.PluginSinks) == 0 {
return fmt.Errorf("pipeline[%d]: no sinks defined", i)
}
for _, src := range p.PluginSources {
if src.Type != "console" {
continue
}
if stdinReader != "" {
return fmt.Errorf("pipeline %q: a console source already reads stdin in pipeline %q", p.Name, stdinReader)
}
stdinReader = p.Name
}
}
return nil
+3
View File
@@ -15,6 +15,9 @@ const (
CapMultiSession Capability = "multi_session"
CapSingleInstance Capability = "single_instance"
// Flow capabilities
CapBackpressure Capability = "backpressure" // a full input stalls the pipeline instead of dropping
// Stream capabilities
CapBidirectional Capability = "bidirectional"
CapCompression Capability = "compression"
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net"
"testing"
"logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/testutil"
)
// The host literal picks the family, strictly: an IPv4 literal or the empty
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"fmt"
"sync/atomic"
"logwisp/internal/config"
"logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/log"
)
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"sync"
"sync/atomic"
"logwisp/internal/config"
"logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
+4 -4
View File
@@ -5,10 +5,10 @@ import (
"fmt"
"sync/atomic"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/filter"
"logwisp/internal/format"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/filter"
"github.com/lixenwraith/logwisp/internal/format"
"github.com/lixenwraith/log"
)
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"sync/atomic"
"time"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/format"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/format"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"testing"
"time"
"logwisp/internal/config"
"logwisp/internal/format"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/format"
)
// include_timestamp = false drops the timestamp the formatter shows by default
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"strings"
"sync/atomic"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/tokenbucket"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/tokenbucket"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
+12 -6
View File
@@ -6,8 +6,8 @@ import (
"fmt"
"sync"
"logwisp/internal/config"
"logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log/formatter"
@@ -98,15 +98,21 @@ func (a *FormatterAdapter) FormatWithFlags(entry core.LogEntry, customFlags int6
return a.serialize(entry, customFlags), nil
}
// serialize renders an entry under the given flags. The returned slice is a
// copy: the underlying formatter reuses one buffer and sinks retain payloads.
// serialize renders an entry under the given flags as one record ending in
// one newline, which raw output lacks unless the message carried it. The
// result is a copy: the formatter reuses one buffer and sinks keep payloads.
func (a *FormatterAdapter) serialize(entry core.LogEntry, flags int64) []byte {
args, flags := formatArgs(entry, flags)
a.mu.Lock()
out := bytes.Clone(a.formatter.Format(flags, entry.Time, mapLevel(entry.Level), sourceLabel(entry), args))
out := a.formatter.Format(flags, entry.Time, mapLevel(entry.Level), sourceLabel(entry), args)
record := make([]byte, len(out), len(out)+1)
copy(record, out)
a.mu.Unlock()
return out
if !bytes.HasSuffix(record, []byte{'\n'}) {
record = append(record, '\n')
}
return record
}
// formatArgs pairs the entry with its flags. FlagRaw keeps the fields JSON
+34
View File
@@ -0,0 +1,34 @@
package format
import (
"bytes"
"testing"
"time"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
)
// Every payload is one record ending in exactly one newline, whatever the
// type and whether or not the message carried its own.
func TestEveryRecordEndsInOneNewline(t *testing.T) {
entries := []core.LogEntry{
{Message: "plain"},
{Message: "carried\n"},
{Fields: []byte(`{"k":1}`)},
{Message: "with", Fields: []byte(`{"k":1}`)},
}
for _, typ := range []string{"raw", "txt", "json"} {
f, err := NewFormatter(&config.FormatConfig{Type: typ})
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
e.Time = time.Now()
out, _ := f.Format(e)
if !bytes.HasSuffix(out, []byte("\n")) || bytes.HasSuffix(out, []byte("\n\n")) {
t.Errorf("%s %+v: %q", typ, e, out)
}
}
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
package format
import (
"logwisp/internal/config"
"logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
)
// Formatter defines the interface for transforming a LogEntry into a byte slice
+62 -33
View File
@@ -7,12 +7,12 @@ import (
"sync/atomic"
"time"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/flow"
"logwisp/internal/session"
"logwisp/internal/sink"
"logwisp/internal/source"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/flow"
"github.com/lixenwraith/logwisp/internal/session"
"github.com/lixenwraith/logwisp/internal/sink"
"github.com/lixenwraith/logwisp/internal/source"
"github.com/lixenwraith/log"
)
@@ -32,11 +32,17 @@ type Pipeline struct {
Stats *PipelineStats
logger *log.Logger
// Sinks that stall dispatch rather than drop (core.CapBackpressure)
backpressure map[string]bool
// Runtime
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
running atomic.Bool
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
running atomic.Bool
halt chan struct{} // closed by Stop: backpressure sinks drop again
finished chan struct{} // closed when every source has ended
finishedOnce sync.Once
}
// PipelineStats contains runtime statistics for a pipeline
@@ -70,6 +76,9 @@ func NewPipeline(
logger: logger,
ctx: pipelineCtx,
cancel: pipelineCancel,
backpressure: make(map[string]bool),
finished: make(chan struct{}),
}
// Create flow processor
@@ -199,6 +208,9 @@ func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig
var hasTLS, hasAuth bool
for _, c := range s.Capabilities() {
switch c {
case core.CapBackpressure:
p.backpressure[cfg.ID] = true
// Network capabilities
case core.CapNetLimit:
continue // No-op for now, placeholder
@@ -224,31 +236,31 @@ func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig
return nil
}
// run is the central processing loop that connects sources, flow, and sinks
func (p *Pipeline) run() {
// run connects the subscribed sources through the flow to the sinks. It
// returns once every source channel has closed, at Stop or at end of input.
func (p *Pipeline) run(subscriptions []<-chan core.LogEntry) {
defer p.wg.Done()
defer p.finish()
defer p.logger.Info("msg", "Pipeline processing loop stopped", "pipeline", p.Config.Name)
var componentWg sync.WaitGroup
// Start a goroutine for each source to fan-in data
for _, src := range p.Sources {
for _, ch := range subscriptions {
componentWg.Add(1)
go func(s source.Source) {
go func() {
defer componentWg.Done()
ch := s.Subscribe()
// Range allows in-flight data to drain cleanly once Source.Stop() closes the channel
// Range drains in-flight entries once the source closes the channel
for entry := range ch {
if event, passed := p.Flow.Process(entry); passed {
// Use non-blocking dispatcher
p.dispatch(event)
}
}
}(src)
}()
}
// Sinks keep p.ctx until Stop, so the heartbeat ends on its own context
hbCtx, hbCancel := context.WithCancel(p.ctx)
var hbWg sync.WaitGroup
// Start heartbeat generator if enabled
if heartbeatCh := p.Flow.StartHeartbeat(p.ctx); heartbeatCh != nil {
if heartbeatCh := p.Flow.StartHeartbeat(hbCtx); heartbeatCh != nil {
hbWg.Add(1)
go func() {
defer hbWg.Done()
@@ -258,9 +270,8 @@ func (p *Pipeline) run() {
if !ok {
return
}
// Use non-blocking dispatcher
p.dispatch(event)
case <-p.ctx.Done():
case <-hbCtx.Done():
return
}
}
@@ -268,25 +279,36 @@ func (p *Pipeline) run() {
}
componentWg.Wait()
// Terminate internal contexts (heartbeat) once flow is complete
p.cancel()
hbCancel()
hbWg.Wait()
}
// dispatch performs a non-blocking send to all sinks.
// A full/stalled sink must never block the run loop or starve sibling sinks.
// dispatch sends to every sink. A full sink drops the event so it cannot
// stall its siblings, except a backpressure sink, which waits until Stop.
func (p *Pipeline) dispatch(event core.TransportEvent) {
for _, snk := range p.Sinks {
for id, snk := range p.Sinks {
if p.backpressure[id] {
select {
case snk.Input() <- event:
case <-p.halt:
p.Stats.TotalEntriesDroppedBySink.Add(1)
}
continue
}
select {
case snk.Input() <- event:
default:
// Buffer full - drop to avoid deadlocking the pipeline
p.Stats.TotalEntriesDroppedBySink.Add(1)
}
}
}
func (p *Pipeline) finish() { p.finishedOnce.Do(func() { close(p.finished) }) }
// Finished is closed when every source has ended, or the pipeline shut down.
// Only a finite source (console at end of input) ends without Stop.
func (p *Pipeline) Finished() <-chan struct{} { return p.finished }
// Start starts the pipeline operation and all its components including flow, sources, and sinks
func (p *Pipeline) Start() error {
if !p.running.CompareAndSwap(false, true) {
@@ -295,6 +317,7 @@ func (p *Pipeline) Start() error {
p.logger.Info("msg", "Starting pipeline", "pipeline", p.Config.Name)
p.ctx, p.cancel = context.WithCancel(context.Background())
p.halt = make(chan struct{})
// Start all sinks
for id, s := range p.Sinks {
@@ -303,7 +326,11 @@ func (p *Pipeline) Start() error {
}
}
// Start all sources
// Subscribed before Start: a source publishes from its first entry on
subscriptions := make([]<-chan core.LogEntry, 0, len(p.Sources))
for _, src := range p.Sources {
subscriptions = append(subscriptions, src.Subscribe())
}
for id, src := range p.Sources {
if err := src.Start(); err != nil {
return fmt.Errorf("failed to start source %s: %w", id, err)
@@ -313,7 +340,7 @@ func (p *Pipeline) Start() error {
// Start the central processing loop
p.Stats.StartTime = time.Now()
p.wg.Add(1)
go p.run()
go p.run(subscriptions)
return nil
}
@@ -325,6 +352,7 @@ func (p *Pipeline) Stop() error {
}
p.logger.Info("msg", "Stopping pipeline", "pipeline", p.Config.Name)
close(p.halt)
// 1. Stop all sources concurrently to halt new data ingress and close their channels
var sourceWg sync.WaitGroup
@@ -338,7 +366,6 @@ func (p *Pipeline) Stop() error {
sourceWg.Wait()
// 2. Wait for the run loop to finish processing and sending all in-flight data
// run() inherently calls p.cancel() when the source channels are empty
p.wg.Wait()
// 3. Stop all sinks concurrently now that no new data will be sent
@@ -351,6 +378,7 @@ func (p *Pipeline) Stop() error {
}(s)
}
sinkWg.Wait()
p.cancel()
p.logger.Info("msg", "Pipeline stopped", "pipeline", p.Config.Name)
return nil
@@ -373,6 +401,7 @@ func (p *Pipeline) Shutdown() {
if p.Sessions != nil {
p.Sessions.Stop()
}
p.finish() // a pipeline that never started has no run loop to close it
p.logger.Info("msg", "Pipeline shutdown complete",
"component", "pipeline",
+132
View File
@@ -0,0 +1,132 @@
package pipeline
import (
"context"
"fmt"
"strconv"
"testing"
"time"
"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/logwisp/internal/source"
"github.com/lixenwraith/log"
)
// finiteSource publishes n entries as soon as it starts, then ends
type finiteSource struct {
n int
subs []chan core.LogEntry
}
func (s *finiteSource) Capabilities() []core.Capability { return nil }
func (s *finiteSource) Subscribe() <-chan core.LogEntry {
ch := make(chan core.LogEntry, 1)
s.subs = append(s.subs, ch)
return ch
}
func (s *finiteSource) Start() error {
go func() {
for i := range s.n {
for _, ch := range s.subs {
ch <- core.LogEntry{Time: time.Now(), Message: strconv.Itoa(i)}
}
}
for _, ch := range s.subs {
close(ch)
}
}()
return nil
}
func (s *finiteSource) Stop() {}
func (s *finiteSource) GetStats() source.SourceStats { return source.SourceStats{} }
// slowSink applies backpressure; a nil out never reads at all
type slowSink struct {
in chan core.TransportEvent
out chan string
}
func (s *slowSink) Capabilities() []core.Capability { return []core.Capability{core.CapBackpressure} }
func (s *slowSink) Input() chan<- core.TransportEvent { return s.in }
func (s *slowSink) Start(context.Context) error {
if s.out != nil {
go func() {
for e := range s.in {
time.Sleep(10 * time.Microsecond)
s.out <- e.Entry.Message
}
}()
}
return nil
}
func (s *slowSink) Stop() {}
func (s *slowSink) GetStats() sink.SinkStats { return sink.SinkStats{} }
var testSinks = map[string]*slowSink{}
func init() {
plugin.RegisterSource("test_finite", func(id string, cfg map[string]any, _ *log.Logger, _ *session.Proxy) (source.Source, error) {
n, _ := strconv.Atoi(fmt.Sprint(cfg["n"]))
return &finiteSource{n: n}, nil
})
plugin.RegisterSink("test_slow", func(id string, _ map[string]any, _ *log.Logger, _ *session.Proxy) (sink.Sink, error) {
return testSinks[id], nil
})
}
func newTestPipeline(t *testing.T, n int, s *slowSink) *Pipeline {
t.Helper()
testSinks[t.Name()] = s
p, err := NewPipeline(&config.PipelineConfig{
Name: t.Name(),
PluginSources: []config.PluginSourceConfig{{ID: "src", Type: "test_finite", Config: map[string]any{"n": n}}},
PluginSinks: []config.PluginSinkConfig{{ID: t.Name(), Type: "test_slow"}},
}, log.NewLogger())
if err != nil {
t.Fatal(err)
}
return p
}
// A source that publishes from its first moment loses nothing to a slow sink,
// and the pipeline reports Finished when its input ends.
func TestFiniteInputReachesBackpressureSinkWhole(t *testing.T) {
const n = 2000
s := &slowSink{in: make(chan core.TransportEvent, 1), out: make(chan string, n)}
p := newTestPipeline(t, n, s)
if err := p.Start(); err != nil {
t.Fatal(err)
}
select {
case <-p.Finished():
case <-time.After(10 * time.Second):
t.Fatal("pipeline did not finish at the end of its input")
}
for i := range n {
if got := <-s.out; got != strconv.Itoa(i) {
t.Fatalf("entry %d = %s", i, got)
}
}
p.Shutdown()
}
// Stop releases a dispatch blocked on a sink that never reads
func TestStopReleasesStalledBackpressureSink(t *testing.T) {
p := newTestPipeline(t, 100, &slowSink{in: make(chan core.TransportEvent, 1)})
if err := p.Start(); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
stopped := make(chan struct{})
go func() { p.Shutdown(); close(stopped) }()
select {
case <-stopped:
case <-time.After(5 * time.Second):
t.Fatal("Stop blocked behind a stalled backpressure sink")
}
}
+4 -4
View File
@@ -4,10 +4,10 @@ import (
"fmt"
"sync"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/sink"
"logwisp/internal/source"
"github.com/lixenwraith/logwisp/internal/plugin"
"github.com/lixenwraith/logwisp/internal/session"
"github.com/lixenwraith/logwisp/internal/sink"
"github.com/lixenwraith/logwisp/internal/source"
"github.com/lixenwraith/log"
)
+4 -4
View File
@@ -4,10 +4,10 @@ import (
"fmt"
"sync"
"logwisp/internal/core"
"logwisp/internal/session"
"logwisp/internal/sink"
"logwisp/internal/source"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/session"
"github.com/lixenwraith/logwisp/internal/sink"
"github.com/lixenwraith/logwisp/internal/source"
"github.com/lixenwraith/log"
)
-70
View File
@@ -1,70 +0,0 @@
package sanitize
import (
"encoding/hex"
"strconv"
"strings"
"unicode/utf8"
)
// String sanitizes a string by replacing non-printable characters with hex encoding
// Non-printable characters are encoded as <hex> (e.g., newline becomes <0a>)
func String(data string) string {
// Fast path: check if sanitization is needed
needsSanitization := false
for _, r := range data {
if !strconv.IsPrint(r) {
needsSanitization = true
break
}
}
if !needsSanitization {
return data
}
// Pre-allocate builder for efficiency
var builder strings.Builder
builder.Grow(len(data))
for _, r := range data {
if strconv.IsPrint(r) {
builder.WriteRune(r)
} else {
// Encode non-printable rune as <hex>
var runeBytes [utf8.UTFMax]byte
n := utf8.EncodeRune(runeBytes[:], r)
builder.WriteByte('<')
builder.WriteString(hex.EncodeToString(runeBytes[:n]))
builder.WriteByte('>')
}
}
return builder.String()
}
// Bytes sanitizes a byte slice by converting to string and sanitizing
func Bytes(data []byte) []byte {
return []byte(String(string(data)))
}
// Rune sanitizes a single rune, returning its string representation
func Rune(r rune) string {
if strconv.IsPrint(r) {
return string(r)
}
var runeBytes [utf8.UTFMax]byte
n := utf8.EncodeRune(runeBytes[:], r)
return "<" + hex.EncodeToString(runeBytes[:n]) + ">"
}
// IsSafe checks if a string contains only printable characters
func IsSafe(data string) bool {
for _, r := range data {
if !strconv.IsPrint(r) {
return false
}
}
return true
}
+21 -2
View File
@@ -6,8 +6,8 @@ import (
"fmt"
"sync"
"logwisp/internal/config"
"logwisp/internal/pipeline"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/pipeline"
"github.com/lixenwraith/log"
)
@@ -21,6 +21,8 @@ type Service struct {
wg sync.WaitGroup
logger *log.Logger
shutdownOnce sync.Once
watchOnce sync.Once
done chan struct{} // closed when every pipeline has finished
}
// NewService creates a new, empty service
@@ -31,6 +33,7 @@ func NewService(ctx context.Context, cfg *config.Config, logger *log.Logger) (*S
ctx: serviceCtx,
cancel: cancel,
logger: logger,
done: make(chan struct{}),
}
var errs error
@@ -89,10 +92,26 @@ func (svc *Service) Start(names ...string) error {
}
svc.logger.Debug("msg", "Finished starting pipeline(s)", "pipelines", names)
svc.watchOnce.Do(func() {
pipelines := make([]*pipeline.Pipeline, 0, len(svc.pipelines))
for _, p := range svc.pipelines {
pipelines = append(pipelines, p)
}
go func() {
for _, p := range pipelines {
<-p.Finished()
}
close(svc.done)
}()
})
return errs
}
// Done is closed once every pipeline has finished: all their sources reached
// the end of their input (stdin), or the service shut down.
func (svc *Service) Done() <-chan struct{} { return svc.done }
// Stop stops all or specific pipeline
func (svc *Service) Stop(names ...string) error {
svc.mu.RLock()
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"sync"
"time"
"logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/core"
)
// Session represents a connection session
+117 -41
View File
@@ -1,21 +1,26 @@
package console
import (
"bytes"
"context"
"encoding/hex"
"fmt"
"io"
"os"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/sink"
"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"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
"golang.org/x/term"
)
// init registers the component in plugin factory
@@ -25,7 +30,7 @@ func init() {
}
}
// ConsoleSink writes log entries to the console (stdout/stderr) using an dedicated logger instance
// ConsoleSink writes formatted entries to stdout or stderr
type ConsoleSink struct {
// Plugin identity and session management
id string
@@ -34,6 +39,7 @@ type ConsoleSink struct {
// Configuration
config *config.ConsoleSinkOptions
escape bool
// Application
input chan core.TransportEvent
@@ -42,6 +48,9 @@ type ConsoleSink struct {
// Runtime
done chan struct{}
exited chan struct{}
started atomic.Bool
stopOnce sync.Once
startTime time.Time
// Statistics
@@ -53,6 +62,7 @@ const (
// Defaults
DefaultConsoleTarget = "stdout"
DefaultConsoleBufferSize = 1000
DefaultConsoleEscape = "auto"
)
// NewConsoleSinkPlugin creates a console sink through plugin factory
@@ -62,28 +72,22 @@ func NewConsoleSinkPlugin(
logger *log.Logger,
proxy *session.Proxy,
) (sink.Sink, error) {
opts := &config.ConsoleSinkOptions{}
opts := &config.ConsoleSinkOptions{Target: DefaultConsoleTarget, Escape: DefaultConsoleEscape}
// Scan config map into struct
if err := config.Scan(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
// Validate and apply defaults
if opts.Target == "" {
opts.Target = DefaultConsoleTarget
} else {
validateTarget := lconfig.OneOf("stdout", "stderr")
if err := validateTarget(opts.Target); err != nil {
return nil, fmt.Errorf("target: %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)
}
var output io.Writer
switch opts.Target {
case "stdout":
output = os.Stdout
case "stderr":
output := os.Stdout
if opts.Target == "stderr" {
output = os.Stderr
}
@@ -96,9 +100,11 @@ func NewConsoleSinkPlugin(
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,
}
cs.lastProcessed.Store(time.Time{})
@@ -117,6 +123,7 @@ func NewConsoleSinkPlugin(
"component", "console_sink",
"instance_id", id,
"target", opts.Target,
"escape", cs.escape,
)
return cs, nil
@@ -126,6 +133,7 @@ func NewConsoleSinkPlugin(
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
}
}
@@ -137,6 +145,7 @@ func (cs *ConsoleSink) Input() chan<- core.TransportEvent {
// 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",
@@ -144,7 +153,7 @@ func (cs *ConsoleSink) Start(ctx context.Context) error {
return nil
}
// Stop gracefully shuts down the sink
// Stop writes what is queued, then returns
func (cs *ConsoleSink) Stop() {
cs.logger.Info("msg", "Stopping console sink", "target", cs.config.Target)
@@ -153,12 +162,14 @@ func (cs *ConsoleSink) Stop() {
cs.proxy.RemoveSession(cs.session.ID)
}
close(cs.done)
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,
"instance_id", cs.id,
)
}
@@ -175,35 +186,100 @@ func (cs *ConsoleSink) GetStats() sink.SinkStats {
Details: map[string]any{
"target": cs.config.Target,
"buffer_size": cs.config.BufferSize,
"escape": cs.escape,
},
}
}
// processLoop reads transport events and writes to console
// 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, ok := <-cs.input:
if !ok {
return
}
// Write pre-formatted payload directly to output
if _, err := cs.output.Write(event.Payload); err != nil {
cs.logger.Error("msg", "Failed to write to console",
"component", "console_sink",
"target", cs.config.Target,
"error", err)
continue
}
cs.totalProcessed.Add(1)
cs.lastProcessed.Store(time.Now())
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 _, 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())
}
// 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
}
+25
View File
@@ -0,0 +1,25 @@
package console
import "testing"
// Everything a terminal would act on is escaped; tabs and the final newline,
// which it only lays out, are kept.
func TestEscapeControlsKeepsOnlyLayout(t *testing.T) {
for in, want := range map[string]string{
"plain\ttext\n": "plain\ttext\n",
"a\x1b[2Jb\n": "a<1b>[2Jb\n",
"bell\x07 del\x7f\n": "bell<07> del<7f>\n",
"inner\nnewline\n": "inner<0a>newline\n",
"c1 \u009b31m\n": "c1 <c29b>31m\n",
"bidi \u202eevil\n": "bidi <e280ae>evil\n",
"isolate \u2066x\u2069\n": "isolate <e281a6>x<e281a9>\n",
"line\u2028sep\n": "line<e280a8>sep\n",
"zwj \U0001f468\u200d\U0001f469\n": "zwj \U0001f468\u200d\U0001f469\n",
"bad \xff utf8": "bad <ff> utf8",
"ünïcødé and \ufffd kept\n": "ünïcødé and \ufffd kept\n",
} {
if got := string(escapeControls([]byte(in))); got != want {
t.Errorf("%q: got %q, want %q", in, got, want)
}
}
}
+37 -19
View File
@@ -6,11 +6,11 @@ import (
"sync/atomic"
"time"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/sink"
"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"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
@@ -41,6 +41,8 @@ type FileSink struct {
// Runtime
done chan struct{}
exited chan struct{}
started atomic.Bool
startTime time.Time
// Statistics
@@ -132,6 +134,7 @@ func NewFileSinkPlugin(
writer: log.NewLogger(),
writerConfig: writerConfig,
done: make(chan struct{}),
exited: make(chan struct{}),
logger: logger,
}
fs.lastProcessed.Store(time.Time{})
@@ -178,6 +181,7 @@ func (fs *FileSink) Start(ctx context.Context) error {
}
fs.startTime = time.Now()
fs.started.Store(true)
go fs.processLoop(ctx)
fs.logger.Info("msg", "File sink started",
@@ -205,7 +209,11 @@ func (fs *FileSink) Stop() {
"directory", fs.config.Directory,
"name", fs.config.Name)
// The loop writes what is queued before the writer shuts down
close(fs.done)
if fs.started.Load() {
<-fs.exited
}
// Remove session
if fs.session != nil {
@@ -240,27 +248,37 @@ func (fs *FileSink) GetStats() sink.SinkStats {
}
}
// processLoop reads transport events and writes to file
// processLoop writes transport events until stopped, then what is queued
func (fs *FileSink) processLoop(ctx context.Context) {
defer close(fs.exited)
for {
select {
case event, ok := <-fs.input:
if !ok {
return
}
// Write the pre-formatted payload directly
// The writer handles rotation automatically based on configuration
fs.writer.Write(string(event.Payload))
fs.totalProcessed.Add(1)
fs.lastProcessed.Store(time.Now())
case event := <-fs.input:
fs.write(event)
case <-ctx.Done():
fs.drain()
return
case <-fs.done:
fs.drain()
return
}
}
}
func (fs *FileSink) drain() {
for {
select {
case event := <-fs.input:
fs.write(event)
default:
return
}
}
}
// write hands the formatted payload to the writer, which rotates files
func (fs *FileSink) write(event core.TransportEvent) {
fs.writer.Write(string(event.Payload))
fs.totalProcessed.Add(1)
fs.lastProcessed.Store(time.Now())
}
+9 -9
View File
@@ -16,15 +16,15 @@ import (
"sync/atomic"
"time"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/sink"
"logwisp/internal/tlsx"
"logwisp/internal/version"
"github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/logwisp/internal/chain"
"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/logwisp/internal/tlsx"
"github.com/lixenwraith/logwisp/internal/version"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
+3 -3
View File
@@ -16,9 +16,9 @@ import (
"testing"
"time"
"logwisp/internal/core"
"logwisp/internal/sink"
"logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/sink"
"github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/log"
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"testing"
"time"
"logwisp/internal/authz"
"logwisp/internal/session"
"logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/logwisp/internal/session"
"github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/log"
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"path"
"strings"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config"
)
//go:embed web/*.js web/*.html web/*.css
+8 -8
View File
@@ -17,14 +17,14 @@ import (
"sync/atomic"
"time"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/sink"
"logwisp/internal/tlsx"
"github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/logwisp/internal/chain"
"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/logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
+7 -7
View File
@@ -12,13 +12,13 @@ import (
"testing"
"time"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/session"
ingest "logwisp/internal/source/httpchain"
"logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/session"
ingest "github.com/lixenwraith/logwisp/internal/source/httpchain"
"github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/log"
+5 -5
View File
@@ -6,11 +6,11 @@ import (
"sync/atomic"
"time"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/sink"
"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/log"
)
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"time"
"logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/core"
)
// Sink represents an output data stream.
+7 -7
View File
@@ -12,13 +12,13 @@ import (
"sync/atomic"
"time"
"logwisp/internal/authz"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/sink"
"logwisp/internal/tlsx"
"github.com/lixenwraith/logwisp/internal/authz"
"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/logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"testing"
"time"
"logwisp/internal/session"
"logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/session"
"github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/log"
)
+8 -8
View File
@@ -14,14 +14,14 @@ import (
"sync/atomic"
"time"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/sink"
"logwisp/internal/tlsx"
"github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/logwisp/internal/chain"
"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/logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"testing"
"time"
"logwisp/internal/core"
"logwisp/internal/session"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/session"
"github.com/lixenwraith/log"
)
+120 -57
View File
@@ -2,16 +2,19 @@ package console
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"sync"
"sync/atomic"
"time"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/source"
"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/source"
"github.com/lixenwraith/log"
)
@@ -31,6 +34,61 @@ func init() {
}
}
// One reader serves the process: after a reload the new source continues
// where the old one stopped, and no line is read twice. The channel is
// unbuffered, so a busy pipeline stops the reading of stdin, not its lines.
var (
stdinOnce sync.Once
stdinLines chan string
stdinErr error // set before stdinLines closes
)
func lines() <-chan string {
stdinOnce.Do(func() {
stdinLines = make(chan string)
go func() {
stdinErr = readLines(os.Stdin, stdinLines)
close(stdinLines)
}()
})
return stdinLines
}
// readLines sends r's lines without their terminator (\n or \r\n), the last
// one too when unterminated, until the end of input. A line longer than
// core.MaxLogEntryBytes continues in the next one.
func readLines(r io.Reader, out chan<- string) error {
br := bufio.NewReaderSize(r, 64*1024)
var line []byte
for {
chunk, err := br.ReadSlice('\n')
line = append(line, chunk...)
for len(line) > core.MaxLogEntryBytes {
out <- string(line[:core.MaxLogEntryBytes])
line = append(line[:0], line[core.MaxLogEntryBytes:]...)
}
switch {
case errors.Is(err, bufio.ErrBufferFull):
continue
case err == nil:
line = line[:len(line)-1]
if n := len(line); n > 0 && line[n-1] == '\r' {
line = line[:n-1]
}
}
if len(line) > 0 || err == nil {
out <- string(line)
}
line = line[:0]
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
}
}
// ConsoleSource reads log entries from the standard input stream
type ConsoleSource struct {
// Plugin identity and session management
@@ -46,13 +104,14 @@ type ConsoleSource struct {
logger *log.Logger
// Runtime
done chan struct{}
done chan struct{}
stopOnce sync.Once
// Statistics
totalEntries atomic.Uint64
droppedEntries atomic.Uint64
startTime time.Time
lastEntryTime atomic.Value // time.Time
totalEntries atomic.Uint64
startTime time.Time
lastEntryTime atomic.Value // time.Time
ended atomic.Bool // standard input reached its end
}
const (
@@ -122,7 +181,7 @@ func (s *ConsoleSource) Subscribe() <-chan core.LogEntry {
// Start begins reading from the standard input.
func (s *ConsoleSource) Start() error {
s.startTime = time.Now()
go s.readLoop()
go s.readLoop(lines())
// Update session activity
s.proxy.UpdateActivity(s.session.ID)
@@ -133,20 +192,15 @@ func (s *ConsoleSource) Start() error {
return nil
}
// Stop signals the source to stop reading.
// Stop signals the source to stop reading; readLoop closes the subscribers.
func (s *ConsoleSource) Stop() {
close(s.done)
s.stopOnce.Do(func() { close(s.done) })
// Remove session
if s.session != nil {
s.proxy.RemoveSession(s.session.ID)
}
// Close subscriber channels
for _, ch := range s.subscribers {
close(ch)
}
s.logger.Info("msg", "Console source stopped",
"component", "console_source",
"instance_id", s.id)
@@ -157,67 +211,76 @@ func (s *ConsoleSource) GetStats() source.SourceStats {
lastEntry, _ := s.lastEntryTime.Load().(time.Time)
return source.SourceStats{
Type: "console",
TotalEntries: s.totalEntries.Load(),
DroppedEntries: s.droppedEntries.Load(),
StartTime: s.startTime,
LastEntryTime: lastEntry,
Details: map[string]any{},
ID: s.id,
Type: "console",
TotalEntries: s.totalEntries.Load(),
StartTime: s.startTime,
LastEntryTime: lastEntry,
Details: map[string]any{"ended": s.ended.Load()},
}
}
// readLoop continuously reads lines from stdin and publishes them
func (s *ConsoleSource) readLoop() {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
// readLoop publishes stdin's lines until Stop or the end of input, then
// closes the subscriber channels: it is their only sender.
func (s *ConsoleSource) readLoop(lines <-chan string) {
defer func() {
for _, ch := range s.subscribers {
close(ch)
}
}()
for {
select {
case <-s.done:
return
default:
// Update session activity on each read
s.proxy.UpdateActivity(s.session.ID)
// Get raw line
lineBytes := scanner.Bytes()
if len(lineBytes) == 0 {
case line, ok := <-lines:
if !ok {
s.ended.Store(true)
if stdinErr != nil {
s.logger.Error("msg", "Failed to read standard input",
"component", "console_source",
"instance_id", s.id,
"error", stdinErr)
}
s.logger.Info("msg", "Standard input ended",
"component", "console_source",
"instance_id", s.id)
return
}
if line == "" {
continue
}
// Add newline back (scanner strips it)
lineWithNewline := append(lineBytes, '\n')
s.proxy.UpdateActivity(s.session.ID)
entry := core.LogEntry{
Time: time.Now(),
Source: "console",
Message: string(lineWithNewline), // Keep newline
Level: source.ExtractLogLevel(string(lineBytes)),
RawSize: int64(len(lineWithNewline)),
Message: line,
Level: source.ExtractLogLevel(line),
RawSize: int64(len(line)),
}
if !s.publish(entry) {
return
}
s.publish(entry)
}
}
if err := scanner.Err(); err != nil {
s.logger.Error("msg", "Scanner error reading stdin",
"component", "console_source",
"instance_id", s.id,
"error", err)
}
}
// publish sends a log entry to all subscribers
func (s *ConsoleSource) publish(entry core.LogEntry) {
// publish waits for every subscriber: stdin is pulled, so a full pipeline
// slows the reading instead of losing lines. False when stopped meanwhile.
func (s *ConsoleSource) publish(entry core.LogEntry) bool {
s.totalEntries.Add(1)
s.lastEntryTime.Store(entry.Time)
for _, ch := range s.subscribers {
select {
case ch <- entry:
case ch <- entry: // first: a select with both ready picks at random
continue
default:
s.droppedEntries.Add(1)
s.logger.Debug("msg", "Dropped log entry - subscriber buffer full",
"component", "console_source")
}
select {
case ch <- entry:
case <-s.done:
return false
}
}
return true
}
+5 -5
View File
@@ -13,11 +13,11 @@ import (
"syscall"
"time"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/source"
"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/source"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"testing"
"time"
"logwisp/internal/core"
"logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/testutil"
)
func TestStoppedWatcherReturnsNormally(t *testing.T) {
+3 -4
View File
@@ -13,8 +13,8 @@ import (
"syscall"
"time"
"logwisp/internal/core"
"logwisp/internal/source"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/source"
"github.com/lixenwraith/log"
)
@@ -379,12 +379,11 @@ func (w *fileWatcher) isStopped() bool {
// parseLine converts a line into an entry, as JSON when nothing would be lost
func (w *fileWatcher) parseLine(line string) core.LogEntry {
if w.raw {
// Newline restored: sinks write the payload as it stands
return core.LogEntry{
Time: time.Now(),
Source: filepath.Base(w.directory),
Level: source.ExtractLogLevel(line),
Message: line + "\n",
Message: line,
}
}
+8 -8
View File
@@ -15,14 +15,14 @@ import (
"sync/atomic"
"time"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/source"
"logwisp/internal/tlsx"
"github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/logwisp/internal/chain"
"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/source"
"github.com/lixenwraith/logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
+5 -5
View File
@@ -5,11 +5,11 @@ import (
"sync/atomic"
"time"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/source"
"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/source"
"github.com/lixenwraith/log"
)
+5 -5
View File
@@ -8,11 +8,11 @@ import (
"sync/atomic"
"time"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/source"
"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/source"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"time"
"logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/core"
)
// Source represents an input data stream for log entries
+8 -8
View File
@@ -13,14 +13,14 @@ import (
"sync/atomic"
"time"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
"logwisp/internal/session"
"logwisp/internal/source"
"logwisp/internal/tlsx"
"github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/logwisp/internal/chain"
"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/source"
"github.com/lixenwraith/logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"sync"
"time"
"logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/log"
)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/config"
)
// A dialer verifies an IPv6 target by its address: the zone names a local
+24 -4
View File
@@ -1,6 +1,9 @@
package version
import "fmt"
import (
"fmt"
"runtime/debug"
)
var (
// Version is the application version, set at compile time via -ldflags
@@ -11,11 +14,28 @@ var (
BuildTime = "unknown"
)
// init fills what -ldflags left unset from the build info: `go install
// module@version` stamps the module version, a build in a checkout the commit.
func init() {
info, ok := debug.ReadBuildInfo()
if !ok {
return
}
if v := info.Main.Version; Version == "dev" && v != "" && v != "(devel)" {
Version = v
}
for _, s := range info.Settings {
switch {
case s.Key == "vcs.revision" && GitCommit == "unknown":
GitCommit = s.Value[:min(len(s.Value), 12)]
case s.Key == "vcs.time" && BuildTime == "unknown":
BuildTime = s.Value
}
}
}
// String returns a detailed, formatted version string including commit and build time
func String() string {
if Version == "dev" {
return fmt.Sprintf("dev (commit: %s, built: %s)", GitCommit, BuildTime)
}
return fmt.Sprintf("%s (commit: %s, built: %s)", Version, GitCommit, BuildTime)
}