v0.13.1 folder restructure, test script added, format adapter async fix

This commit is contained in:
2026-07-17 09:09:00 -04:00
parent 87e57784da
commit b8dd591b4b
49 changed files with 402 additions and 206 deletions
+192
View File
@@ -0,0 +1,192 @@
package main
import (
"context"
"fmt"
_ "logwisp/internal/source/console"
_ "logwisp/internal/source/file"
_ "logwisp/internal/source/httpchain"
_ "logwisp/internal/source/null"
_ "logwisp/internal/source/random"
_ "logwisp/internal/source/tcpchain"
_ "logwisp/internal/sink/console"
_ "logwisp/internal/sink/file"
_ "logwisp/internal/sink/http"
_ "logwisp/internal/sink/httpchain"
_ "logwisp/internal/sink/null"
_ "logwisp/internal/sink/tcp"
_ "logwisp/internal/sink/tcpchain"
"logwisp/internal/config"
"logwisp/internal/service"
"logwisp/internal/version"
"github.com/lixenwraith/log"
"github.com/lixenwraith/log/sanitizer"
)
// bootstrapInitial handles initial service startup with status reporter
func bootstrapInitial(ctx context.Context, cfg *config.Config) (*service.Service, context.CancelFunc, error) {
svc, err := bootstrapService(ctx, cfg)
if err != nil {
return nil, nil, fmt.Errorf("failed to bootstrap service: %w", err)
}
if err := svc.Start(); err != nil {
return nil, nil, fmt.Errorf("failed to start service pipelines: %w", err)
}
var statusCancel context.CancelFunc
if cfg.StatusReporter {
statusCancel = startStatusReporter(ctx, svc)
}
return svc, statusCancel, nil
}
// handleReload orchestrates the entire hot-reload process including status reporter lifecycle
func handleReload(ctx context.Context, oldSvc *service.Service, statusCancel context.CancelFunc) (*service.Service, *config.Config, context.CancelFunc, error) {
logger.Info("msg", "Starting configuration hot reload")
// Get updated config from the lixenwraith/config manager
lcfg := config.GetConfigManager()
if lcfg == nil {
err := fmt.Errorf("config manager not available for reload")
logger.Error("msg", "Reload failed", "error", err)
return nil, nil, nil, err
}
updatedCfgStruct, err := lcfg.AsStruct()
if err != nil {
logger.Error("msg", "Failed to get updated config for reload", "error", err, "action", "keeping current configuration")
return nil, nil, nil, err
}
newCfg := updatedCfgStruct.(*config.Config)
// Bootstrap a new service to ensure it's valid before touching the old one
logger.Debug("msg", "Bootstrapping new service with updated config")
newService, err := bootstrapService(ctx, newCfg)
if err != nil {
logger.Error("msg", "Failed to bootstrap new service, keeping old service running", "error", err)
return nil, nil, nil, err
}
// Gracefully shut down the old service
if oldSvc != nil {
logger.Info("msg", "Shutting down old service before activating new one")
oldSvc.Shutdown()
}
// Start the new service
if err := newService.Start(); err != nil {
logger.Error("msg", "Failed to start new service pipelines after reload. The application may be in a non-functional state.", "error", err)
return nil, nil, nil, fmt.Errorf("failed to start new service: %w", err)
}
// Manage status reporter lifecycle
if statusCancel != nil {
statusCancel()
}
var newStatusCancel context.CancelFunc
if newCfg.StatusReporter {
newStatusCancel = startStatusReporter(ctx, newService)
}
logger.Info("msg", "Configuration hot reload completed successfully")
return newService, newCfg, newStatusCancel, nil
}
// bootstrapService creates and initializes the main log transport service and its pipelines
func bootstrapService(ctx context.Context, cfg *config.Config) (*service.Service, error) {
// Create service with logger dependency injection
svc, err := service.NewService(ctx, cfg, logger)
if err != nil {
logger.Error("msg", "Failed to initialize service",
"component", "bootstrap",
)
return nil, err
}
logger.Info("msg", "LogWisp started",
"version", version.Short(),
)
return svc, nil
}
// initializeLogger sets up the global logger based on the application's configuration
func initializeLogger(cfg *config.Config) error {
logger = log.NewLogger()
logCfg := log.DefaultConfig()
if cfg.Quiet {
// In quiet mode, disable ALL logging output
logCfg.Level = 255 // A level that disables all output
logCfg.EnableFile = false
logCfg.EnableConsole = false
return logger.ApplyConfig(logCfg)
}
// Determine log level
levelValue, err := log.Level(cfg.Logging.Level)
if err != nil {
return fmt.Errorf("invalid log level: %w", err)
}
logCfg.Level = levelValue
// Configure log format
if cfg.Logging.Format != "" {
logCfg.Format = cfg.Logging.Format
}
if cfg.Logging.Sanitization != "" {
logCfg.Sanitization = sanitizer.PolicyPreset(cfg.Logging.Sanitization)
}
// Configure based on output mode
switch cfg.Logging.Output {
case "none":
logCfg.EnableFile = false
logCfg.EnableConsole = false
case "stdout":
logCfg.EnableFile = false
logCfg.EnableConsole = true
logCfg.ConsoleTarget = "stdout"
case "stderr":
logCfg.EnableFile = false
logCfg.EnableConsole = true
logCfg.ConsoleTarget = "stderr"
case "split":
logCfg.EnableFile = false
logCfg.EnableConsole = true
logCfg.ConsoleTarget = "split"
case "file":
logCfg.EnableFile = true
logCfg.EnableConsole = false
configureFileLogging(logCfg, cfg)
case "all":
logCfg.EnableFile = true
logCfg.EnableConsole = true
logCfg.ConsoleTarget = "split"
configureFileLogging(logCfg, cfg)
default:
return fmt.Errorf("invalid log output mode: %s", cfg.Logging.Output)
}
return logger.ApplyConfig(logCfg)
}
// configureFileLogging sets up file-based logging parameters from the configuration
func configureFileLogging(logCfg *log.Config, cfg *config.Config) {
if cfg.Logging.File != nil {
logCfg.Directory = cfg.Logging.File.Directory
logCfg.Name = cfg.Logging.File.Name
logCfg.MaxSizeKB = cfg.Logging.File.MaxSizeMB * 1000
logCfg.MaxTotalSizeKB = cfg.Logging.File.MaxTotalSizeMB * 1000
if cfg.Logging.File.RetentionHours > 0 {
logCfg.RetentionPeriodHrs = cfg.Logging.File.RetentionHours
}
}
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"fmt"
"logwisp/internal/version"
"os"
)
// helpText is the CLI usage reference. Flags map 1:1 to TOML config paths.
const helpText = `LogWisp %s - log collection, processing, and distribution
Usage:
logwisp [options]
logwisp help | -h | --help
logwisp --version
Any configuration key is settable as a flag using its TOML path:
--<path>=<value> e.g. --logging.level=debug
Common options:
-c, --config <path> Configuration file (default: ./logwisp.toml)
--quiet Suppress console output
--status_reporter=<bool> Periodic status logging (default: true)
--auto_reload=<bool> Config hot reload on file change (default: false)
Logging:
--logging.output=<mode> file|stdout|stderr|split|all|none
--logging.level=<level> debug|info|warn|error
--logging.file.directory=<path>
--logging.console.target=<target> stdout|stderr|split
Pipelines (N = 0-based index):
--pipelines.N.name=<name>
--pipelines.N.plugin_sources.N.type=<type> file|console|random|null
--pipelines.N.plugin_sinks.N.type=<type> console|file|http|tcp|null
--pipelines.N.flow.filters.N.patterns='["ERROR","WARN"]'
Environment:
LOGWISP_<PATH> Config path, '.' -> '_', uppercase
e.g. LOGWISP_LOGGING_LEVEL=debug
LOGWISP_CONFIG_FILE Configuration file path
LOGWISP_CONFIG_DIR Configuration directory
Signals:
SIGINT, SIGTERM Graceful shutdown
SIGHUP, SIGUSR1 Reload configuration
Exit codes:
0 success
1 general error
2 configuration file not found
`
// handleHelp prints usage and exits if a help request is present in args
func handleHelp(args []string) {
if len(args) > 0 && args[0] == "help" {
printHelp()
}
for _, arg := range args {
if arg == "--" {
break // end of flags
}
if arg == "-h" || arg == "--help" {
printHelp()
}
}
}
// printHelp writes usage to stdout and exits with success
func printHelp() {
fmt.Printf(helpText, version.Short())
os.Exit(0)
}
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/version"
"github.com/lixenwraith/log"
)
// logger is the global logger instance for the application
var logger *log.Logger
// main is the entry point for the LogWisp application
func main() {
// --- 1. Initial setup ---
// Emulates nohup
signal.Ignore(syscall.SIGHUP)
// Help handled before config parsing; loader has no help flag.
// Also the future dispatch point for subcommands (tls, etc.)
handleHelp(os.Args[1:])
// Load configuration with automatic CLI parsing
cfg, err := config.Load(os.Args[1:])
if err != nil {
if strings.Contains(err.Error(), "not found") && cfg != nil && cfg.ConfigFile != "" {
fmt.Fprintf(os.Stderr, "Error: Config file not found: %s\n", cfg.ConfigFile)
os.Exit(2)
}
fmt.Fprintf(os.Stderr, "Error: Failed to load config: %v\n", err)
os.Exit(1)
}
// Initialize output handler
InitOutputHandler(cfg.Quiet)
// Handle version
if cfg.ShowVersion {
fmt.Println(version.String())
os.Exit(0)
}
// Initialize logger instance and apply configuration
if err := initializeLogger(cfg); err != nil {
FatalError(1, "Failed to initialize logger: %v\n", err)
}
defer shutdownLogger()
// Start the logger
if err := logger.Start(); err != nil {
FatalError(1, "Failed to start logger: %v\n", err)
}
// Log startup information
logger.Info("msg", "LogWisp starting",
"version", version.String(),
"config_file", cfg.ConfigFile,
"log_output", cfg.Logging.Output,
"status_reporter", cfg.StatusReporter,
"auto_reload", cfg.ConfigAutoReload)
// Create context for shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// --- 2. Bootstrap initial service ---
svc, statusReporterCancel, err := bootstrapInitial(ctx, cfg)
if err != nil {
logger.Error("msg", "Failed to initialize service", "error", err)
os.Exit(1)
}
// --- 3. Setup signals and shutdown ---
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1)
var configChanges <-chan string
lcfg := config.GetConfigManager()
if cfg.ConfigAutoReload && lcfg != nil {
configChanges = lcfg.Watch()
logger.Info("msg", "Config auto-reload enabled", "config_file", cfg.ConfigFile)
} else {
logger.Info("msg", "Config auto-reload disabled")
}
// Service shutdown sequence
defer func() {
logger.Info("msg", "Shutdown initiated")
if statusReporterCancel != nil {
statusReporterCancel()
}
if svc != nil {
svc.Shutdown()
}
if lcfg != nil {
lcfg.StopAutoUpdate()
}
logger.Info("msg", "Shutdown complete")
// Deferred logger shutdown will run after this
}()
// --- 4. Main Application Event Loop ---
logger.Info("msg", "Application started, waiting for signals or config changes")
for {
select {
case sig := <-sigChan:
if sig == syscall.SIGHUP || sig == syscall.SIGUSR1 {
logger.Info("msg", "Reload signal received, triggering manual reload", "signal", sig)
newSvc, newCfg, newStatusCancel, err := handleReload(ctx, svc, statusReporterCancel)
if err == nil {
svc = newSvc
cfg = newCfg
statusReporterCancel = newStatusCancel
}
} else {
logger.Info("msg", "Shutdown signal received", "signal", sig)
cancel() // Trigger service shutdown via context
}
case event, ok := <-configChanges:
if !ok {
logger.Warn("msg", "Configuration watch channel closed, disabling auto-reload")
configChanges = nil // Stop selecting on this channel
continue
}
logger.Info("msg", "Configuration file change detected, triggering reload", "event", event)
newSvc, newCfg, newStatusCancel, err := handleReload(ctx, svc, statusReporterCancel)
if err == nil {
svc = newSvc
cfg = newCfg
statusReporterCancel = newStatusCancel
}
case <-ctx.Done():
return // Exit the loop and trigger deferred shutdown
}
}
}
// shutdownLogger gracefully shuts down the global logger.
func shutdownLogger() {
if logger != nil {
if err := logger.Shutdown(core.LoggerShutdownTimeout); err != nil {
// Best effort - can't log the shutdown error
Error("Logger shutdown error: %v\n", err)
}
}
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"fmt"
"io"
"os"
"sync"
)
// OutputHandler manages all application output, respecting the global quiet mode
type OutputHandler struct {
quiet bool
mu sync.RWMutex
stdout io.Writer
stderr io.Writer
}
// output is the global instance of the OutputHandler
var output *OutputHandler
// InitOutputHandler initializes the global output handler
func InitOutputHandler(quiet bool) {
output = &OutputHandler{
quiet: quiet,
stdout: os.Stdout,
stderr: os.Stderr,
}
}
// Print writes to stdout
func Print(format string, args ...any) {
if output != nil {
output.Print(format, args...)
}
}
// Error writes to stderr
func Error(format string, args ...any) {
if output != nil {
output.Error(format, args...)
}
}
// FatalError writes to stderr and exits the application
func FatalError(code int, format string, args ...any) {
if output != nil {
output.FatalError(code, format, args...)
} else {
// Fallback if handler not initialized
fmt.Fprintf(os.Stderr, format, args...)
os.Exit(code)
}
}
// Print writes a formatted string to stdout if not in quiet mode
func (o *OutputHandler) Print(format string, args ...any) {
o.mu.RLock()
defer o.mu.RUnlock()
if !o.quiet {
fmt.Fprintf(o.stdout, format, args...)
}
}
// Error writes a formatted string to stderr if not in quiet mode
func (o *OutputHandler) Error(format string, args ...any) {
o.mu.RLock()
defer o.mu.RUnlock()
if !o.quiet {
fmt.Fprintf(o.stderr, format, args...)
}
}
// FatalError writes a formatted string to stderr and exits with the given code.
func (o *OutputHandler) FatalError(code int, format string, args ...any) {
o.Error(format, args...)
os.Exit(code)
}
// IsQuiet returns the current quiet mode status.
func (o *OutputHandler) IsQuiet() bool {
o.mu.RLock()
defer o.mu.RUnlock()
return o.quiet
}
// SetQuiet updates the quiet mode status.
func (o *OutputHandler) SetQuiet(quiet bool) {
o.mu.Lock()
defer o.mu.Unlock()
o.quiet = quiet
}
+148
View File
@@ -0,0 +1,148 @@
package main
import (
"context"
"fmt"
"time"
"logwisp/internal/service"
)
// startStatusReporter starts a new status reporter for a service and returns its cancel function.
func startStatusReporter(ctx context.Context, svc *service.Service) context.CancelFunc {
reporterCtx, cancel := context.WithCancel(ctx)
go statusReporter(svc, reporterCtx)
logger.Debug("msg", "Started status reporter")
return cancel
}
// statusReporter periodically logs the health and statistics of the service
func statusReporter(service *service.Service, ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if service == nil {
logger.Warn("msg", "Status reporter: service is nil",
"component", "status_reporter")
return
}
// Safely get stats with recovery
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("msg", "Panic in status reporter",
"component", "status_reporter",
"panic", r)
}
}()
stats := service.GetGlobalStats()
totalPipelines, ok := stats["total_pipelines"].(int)
if !ok || totalPipelines == 0 {
logger.Warn("msg", "No active pipelines in status report",
"component", "status_reporter")
return
}
// Log service-level summary
logger.Debug("msg", "Status report",
"component", "status_reporter",
"active_pipelines", totalPipelines,
"time", time.Now().Format("15:04:05"))
// Log each pipeline's stats recursively
if pipelines, ok := stats["pipelines"].(map[string]any); ok {
for name, pipelineStats := range pipelines {
logStats("Pipeline status", name, pipelineStats)
}
}
}()
}
}
}
// logStats recursively logs statistics with automatic field extraction
func logStats(msg string, name string, stats any) {
// Build base log fields
fields := []any{
"msg", msg,
"name", name,
}
// Extract and flatten important metrics from stats map
if statsMap, ok := stats.(map[string]any); ok {
// Add scalar values directly
for key, value := range statsMap {
switch v := value.(type) {
case string, bool, int, int64, uint64, float64:
fields = append(fields, key, v)
case time.Time:
if !v.IsZero() {
fields = append(fields, key, v.Format(time.RFC3339))
}
case map[string]any:
// For nested maps, log summary counts if they contain arrays/maps
if count := getItemCount(v); count > 0 {
fields = append(fields, fmt.Sprintf("%s_count", key), count)
}
case []any, []map[string]any:
// For arrays, just log the count
fields = append(fields, fmt.Sprintf("%s_count", key), getArrayLength(value))
}
}
// Log the flattened stats
logger.Debug(fields...)
// Recursively log nested structures with detail
for key, value := range statsMap {
switch v := value.(type) {
case map[string]any:
// Log nested component stats
if key == "flow" || key == "rate_limiter" || key == "filters" {
logStats(fmt.Sprintf("%s %s", name, key), key, v)
}
case []map[string]any:
// Log array items (sources, sinks, filters)
for i, item := range v {
if itemName, ok := item["id"].(string); ok {
logStats(fmt.Sprintf("%s %s", name, key), itemName, item)
} else {
logStats(fmt.Sprintf("%s %s", name, key), fmt.Sprintf("%s[%d]", key, i), item)
}
}
}
}
}
}
// getItemCount returns the count of items in a map (for nested structures)
func getItemCount(m map[string]any) int {
for _, v := range m {
switch v.(type) {
case []any:
return len(v.([]any))
case []map[string]any:
return len(v.([]map[string]any))
}
}
return 0
}
// getArrayLength safely gets the length of various array types
func getArrayLength(v any) int {
switch arr := v.(type) {
case []any:
return len(arr)
case []map[string]any:
return len(arr)
default:
return 0
}
}