v0.22.1 cli flag and behavior, and test output improvement
This commit is contained in:
1 parent
71d1b82f83
commit
4eba6fc834
89 files changed
+1531
-661
No files matched your search
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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{}}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package config
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"logwisp/internal/testutil"
|
||||
"github.com/lixenwraith/logwisp/internal/testutil"
|
||||
)
|
||||
|
||||
func isolateConfig(t *testing.T) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"logwisp/internal/core"
|
||||
"github.com/lixenwraith/logwisp/internal/core"
|
||||
)
|
||||
|
||||
// Session represents a connection session
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"logwisp/internal/core"
|
||||
"github.com/lixenwraith/logwisp/internal/core"
|
||||
)
|
||||
|
||||
// Sink represents an output data stream.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"logwisp/internal/config"
|
||||
"github.com/lixenwraith/logwisp/internal/config"
|
||||
|
||||
"github.com/lixenwraith/log"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user