diff --git a/go.mod b/go.mod index df9f187..edc5ea4 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module logwisp go 1.24.5 require ( - github.com/lixenwraith/config v0.0.0-20250701170607-8515fa0543b6 - github.com/lixenwraith/log v0.0.0-20250710012114-049926224b0e + github.com/lixenwraith/config v0.0.0-20250712170030-7d38402e0497 + github.com/lixenwraith/log v0.0.0-20250710220042-139c522da9aa github.com/panjf2000/gnet/v2 v2.9.1 github.com/valyala/fasthttp v1.63.0 ) diff --git a/go.sum b/go.sum index 4573c76..992f13f 100644 --- a/go.sum +++ b/go.sum @@ -6,10 +6,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/lixenwraith/config v0.0.0-20250701170607-8515fa0543b6 h1:qE4SpAJWFaLkdRyE0FjTPBBRYE7LOvcmRCB5p86W73Q= -github.com/lixenwraith/config v0.0.0-20250701170607-8515fa0543b6/go.mod h1:4wPJ3HnLrYrtUwTinngCsBgtdIXsnxkLa7q4KAIbwY8= -github.com/lixenwraith/log v0.0.0-20250710012114-049926224b0e h1:WjYl/OIKxDCFA1In2W0bJbCGJ/Ub9X9DL+avZRNjXIQ= -github.com/lixenwraith/log v0.0.0-20250710012114-049926224b0e/go.mod h1:KFE7B7m2pu5kAl0olDCvywlOqFJhanogAhTlVvlp8JE= +github.com/lixenwraith/config v0.0.0-20250712170030-7d38402e0497 h1:ixTIdJSd945n/IhMRwGwQVmQnQ1nUr5z1wn31jXq9FU= +github.com/lixenwraith/config v0.0.0-20250712170030-7d38402e0497/go.mod h1:y7kgDrWIFROWJJ6ASM/SPTRRAj27FjRGWh2SDLcdQ68= +github.com/lixenwraith/log v0.0.0-20250710220042-139c522da9aa h1:lIo780MTgZXH5jF+Qr24fRDFQTQ3eu3OaaFYLP9ZkR0= +github.com/lixenwraith/log v0.0.0-20250710220042-139c522da9aa/go.mod h1:lEjTIMWGW+XGn20x5Ec0qkNK4LCd7Y/04PII51crYKk= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg= diff --git a/src/cmd/logwisp/bootstrap.go b/src/cmd/logwisp/bootstrap.go index a42e1b4..581a612 100644 --- a/src/cmd/logwisp/bootstrap.go +++ b/src/cmd/logwisp/bootstrap.go @@ -4,9 +4,6 @@ package main import ( "context" "fmt" - "os" - "os/exec" - "path/filepath" "strings" "logwisp/src/internal/config" @@ -17,13 +14,13 @@ import ( ) // bootstrapService creates and initializes the log transport service -func bootstrapService(ctx context.Context, cfg *config.Config, flagCfg *FlagConfig) (*service.Service, *service.HTTPRouter, error) { +func bootstrapService(ctx context.Context, cfg *config.Config) (*service.Service, *service.HTTPRouter, error) { // Create service with logger dependency injection svc := service.New(ctx, logger) // Create HTTP router if requested var router *service.HTTPRouter - if flagCfg.UseRouter { + if cfg.UseRouter { router = service.NewHTTPRouter(svc, logger) logger.Info("msg", "HTTP router mode enabled") } @@ -42,7 +39,7 @@ func bootstrapService(ctx context.Context, cfg *config.Config, flagCfg *FlagConf } // If using router mode, register HTTP sinks - if flagCfg.UseRouter { + if cfg.UseRouter { pipeline, err := svc.GetPipeline(pipelineCfg.Name) if err == nil && len(pipeline.HTTPSinks) > 0 { if err := router.RegisterPipeline(pipeline); err != nil { @@ -54,7 +51,7 @@ func bootstrapService(ctx context.Context, cfg *config.Config, flagCfg *FlagConf } successCount++ - displayPipelineEndpoints(pipelineCfg, flagCfg.UseRouter) + displayPipelineEndpoints(pipelineCfg, cfg.UseRouter) } if successCount == 0 { @@ -68,42 +65,31 @@ func bootstrapService(ctx context.Context, cfg *config.Config, flagCfg *FlagConf return svc, router, nil } -// initializeLogger sets up the logger based on configuration and CLI flags -func initializeLogger(cfg *config.Config, flagCfg *FlagConfig) error { +// initializeLogger sets up the logger based on configuration +func initializeLogger(cfg *config.Config) error { logger = log.NewLogger() var configArgs []string - // Quiet mode suppresses ALL LogWisp logging (not sink outputs) - if flagCfg.Quiet { + if cfg.Quiet { // In quiet mode, disable ALL logging output configArgs = append(configArgs, "disable_file=true", "enable_stdout=false", - "level=255") // Set to max level to suppress everything + "level=255") return logger.InitWithDefaults(configArgs...) } - // Determine output mode from CLI or config - outputMode := cfg.Logging.Output - if flagCfg.LogOutput != "" { - outputMode = flagCfg.LogOutput - } - // Determine log level - level := cfg.Logging.Level - if flagCfg.LogLevel != "" { - level = flagCfg.LogLevel - } - levelValue, err := parseLogLevel(level) + levelValue, err := parseLogLevel(cfg.Logging.Level) if err != nil { return fmt.Errorf("invalid log level: %w", err) } configArgs = append(configArgs, fmt.Sprintf("level=%d", levelValue)) // Configure based on output mode - switch outputMode { + switch cfg.Logging.Output { case "none": configArgs = append(configArgs, "disable_file=true", "enable_stdout=false") @@ -121,15 +107,15 @@ func initializeLogger(cfg *config.Config, flagCfg *FlagConfig) error { case "file": configArgs = append(configArgs, "enable_stdout=false") - configureFileLogging(&configArgs, cfg, flagCfg) + configureFileLogging(&configArgs, cfg) case "both": configArgs = append(configArgs, "enable_stdout=true") - configureFileLogging(&configArgs, cfg, flagCfg) - configureConsoleTarget(&configArgs, cfg, flagCfg) + configureFileLogging(&configArgs, cfg) + configureConsoleTarget(&configArgs, cfg) default: - return fmt.Errorf("invalid log output mode: %s", outputMode) + return fmt.Errorf("invalid log output mode: %s", cfg.Logging.Output) } // Apply format if specified @@ -141,20 +127,8 @@ func initializeLogger(cfg *config.Config, flagCfg *FlagConfig) error { } // configureFileLogging sets up file-based logging parameters -func configureFileLogging(configArgs *[]string, cfg *config.Config, flagCfg *FlagConfig) { - // CLI overrides - if flagCfg.LogFile != "" { - dir := filepath.Dir(flagCfg.LogFile) - name := strings.TrimSuffix(filepath.Base(flagCfg.LogFile), filepath.Ext(flagCfg.LogFile)) - *configArgs = append(*configArgs, - fmt.Sprintf("directory=%s", dir), - fmt.Sprintf("name=%s", name)) - } else if flagCfg.LogDir != "" { - *configArgs = append(*configArgs, - fmt.Sprintf("directory=%s", flagCfg.LogDir), - fmt.Sprintf("name=%s", cfg.Logging.File.Name)) - } else if cfg.Logging.File != nil { - // Use config file settings +func configureFileLogging(configArgs *[]string, cfg *config.Config) { + if cfg.Logging.File != nil { *configArgs = append(*configArgs, fmt.Sprintf("directory=%s", cfg.Logging.File.Directory), fmt.Sprintf("name=%s", cfg.Logging.File.Name), @@ -169,12 +143,10 @@ func configureFileLogging(configArgs *[]string, cfg *config.Config, flagCfg *Fla } // configureConsoleTarget sets up console output parameters -func configureConsoleTarget(configArgs *[]string, cfg *config.Config, flagCfg *FlagConfig) { +func configureConsoleTarget(configArgs *[]string, cfg *config.Config) { target := "stderr" // default - if flagCfg.LogConsole != "" { - target = flagCfg.LogConsole - } else if cfg.Logging.Console != nil && cfg.Logging.Console.Target != "" { + if cfg.Logging.Console != nil && cfg.Logging.Console.Target != "" { target = cfg.Logging.Console.Target } @@ -182,39 +154,11 @@ func configureConsoleTarget(configArgs *[]string, cfg *config.Config, flagCfg *F if target == "split" { *configArgs = append(*configArgs, "stdout_split_mode=true") *configArgs = append(*configArgs, "stdout_target=split") - logger.Debug("msg", "Console output configured for split mode", - "component", "bootstrap", - "info_debug", "stdout", - "warn_error", "stderr") } else { *configArgs = append(*configArgs, fmt.Sprintf("stdout_target=%s", target)) } } -// isBackgroundProcess checks if we're already running in background -func isBackgroundProcess() bool { - return os.Getenv("LOGWISP_BACKGROUND") == "1" -} - -// runInBackground starts the process in background -func runInBackground() error { - cmd := exec.Command(os.Args[0], os.Args[1:]...) - cmd.Env = append(os.Environ(), "LOGWISP_BACKGROUND=1") - cmd.Stdin = nil - // Respect quiet mode for background process output - if !output.IsQuiet() { - cmd.Stdout = os.Stdout // Keep stdout for logging - cmd.Stderr = os.Stderr // Keep stderr for logging - } - - if err := cmd.Start(); err != nil { - return err - } - - Print("Started LogWisp in background (PID: %d)\n", cmd.Process.Pid) - return nil -} - func parseLogLevel(level string) (int, error) { switch strings.ToLower(level) { case "debug": diff --git a/src/cmd/logwisp/flags.go b/src/cmd/logwisp/flags.go deleted file mode 100644 index a3cea21..0000000 --- a/src/cmd/logwisp/flags.go +++ /dev/null @@ -1,140 +0,0 @@ -// FILE: src/cmd/logwisp/flags.go -package main - -import ( - "flag" - "fmt" - "os" - "strings" -) - -// Command-line flags -var ( - // General flags - configFile = flag.String("config", "", "Config file path") - useRouter = flag.Bool("router", false, "Use HTTP router for path-based routing") - showVersion = flag.Bool("version", false, "Show version information") - background = flag.Bool("background", false, "Run as background process") - - // Logging flags - logOutput = flag.String("log-output", "", "Log output: file, stdout, stderr, both, none (overrides config)") - logLevel = flag.String("log-level", "", "Log level: debug, info, warn, error (overrides config)") - logFile = flag.String("log-file", "", "Log file path (when using file output)") - logDir = flag.String("log-dir", "", "Log directory (when using file output)") - logConsole = flag.String("log-console", "", "Console target: stdout, stderr, split (overrides config)") - - // Quiet mode flag - quiet = flag.Bool("quiet", false, "Suppress all LogWisp logging output (sink outputs remain unaffected)") -) - -// FlagConfig holds parsed command-line flags -type FlagConfig struct { - ConfigFile string - UseRouter bool - ShowVersion bool - Background bool - LogOutput string - LogLevel string - LogFile string - LogDir string - LogConsole string - Quiet bool -} - -// ParseFlags parses command-line arguments and returns configuration -func ParseFlags() (*FlagConfig, error) { - // Set custom usage before parsing - flag.Usage = customUsage - flag.Parse() - - fc := &FlagConfig{ - ConfigFile: *configFile, - UseRouter: *useRouter, - ShowVersion: *showVersion, - Background: *background, - LogOutput: *logOutput, - LogLevel: *logLevel, - LogFile: *logFile, - LogDir: *logDir, - LogConsole: *logConsole, - Quiet: *quiet, - } - - // Validate log-output flag if provided - if fc.LogOutput != "" { - validOutputs := map[string]bool{ - "file": true, "stdout": true, "stderr": true, - "both": true, "none": true, - } - if !validOutputs[fc.LogOutput] { - return nil, fmt.Errorf("invalid log-output: %s (valid: file, stdout, stderr, both, none)", fc.LogOutput) - } - } - - // Validate log-level flag if provided - if fc.LogLevel != "" { - validLevels := map[string]bool{ - "debug": true, "info": true, "warn": true, "error": true, - } - if !validLevels[strings.ToLower(fc.LogLevel)] { - return nil, fmt.Errorf("invalid log-level: %s (valid: debug, info, warn, error)", fc.LogLevel) - } - } - - // Validate log-console flag if provided - if fc.LogConsole != "" { - validTargets := map[string]bool{ - "stdout": true, "stderr": true, "split": true, - } - if !validTargets[fc.LogConsole] { - return nil, fmt.Errorf("invalid log-console: %s (valid: stdout, stderr, split)", fc.LogConsole) - } - } - - return fc, nil -} - -func customUsage() { - fmt.Fprintf(os.Stderr, "LogWisp - Multi-Pipeline Log Processing Service\n\n") - fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\n", os.Args[0]) - fmt.Fprintf(os.Stderr, "Options:\n") - - // General options - fmt.Fprintf(os.Stderr, "\nGeneral:\n") - fmt.Fprintf(os.Stderr, " -config string\n\tConfig file path\n") - fmt.Fprintf(os.Stderr, " -router\n\tUse HTTP router for path-based routing\n") - fmt.Fprintf(os.Stderr, " -version\n\tShow version information\n") - fmt.Fprintf(os.Stderr, " -background\n\tRun as background process\n") - - // Logging options - fmt.Fprintf(os.Stderr, "\nLogging:\n") - fmt.Fprintf(os.Stderr, " -log-output string\n\tLog output: file, stdout, stderr, both, none (overrides config)\n") - fmt.Fprintf(os.Stderr, " -log-level string\n\tLog level: debug, info, warn, error (overrides config)\n") - fmt.Fprintf(os.Stderr, " -log-file string\n\tLog file path (when using file output)\n") - fmt.Fprintf(os.Stderr, " -log-dir string\n\tLog directory (when using file output)\n") - fmt.Fprintf(os.Stderr, " -log-console string\n\tConsole target: stdout, stderr, split (overrides config)\n") - fmt.Fprintf(os.Stderr, " -quiet\n\tSuppress all LogWisp logging output (sink outputs remain unaffected)\n") - - fmt.Fprintf(os.Stderr, "\nExamples:\n") - fmt.Fprintf(os.Stderr, " # Run with default config (logs to stderr)\n") - fmt.Fprintf(os.Stderr, " %s\n\n", os.Args[0]) - - fmt.Fprintf(os.Stderr, " # Run with file logging\n") - fmt.Fprintf(os.Stderr, " %s --log-output file --log-dir /var/log/logwisp\n\n", os.Args[0]) - - fmt.Fprintf(os.Stderr, " # Run with debug logging to both file and console\n") - fmt.Fprintf(os.Stderr, " %s --log-output both --log-level debug\n\n", os.Args[0]) - - fmt.Fprintf(os.Stderr, " # Run with custom config and override log level\n") - fmt.Fprintf(os.Stderr, " %s --config /etc/logwisp.toml --log-level warn\n\n", os.Args[0]) - - fmt.Fprintf(os.Stderr, " # Run in router mode with multiple pipelines\n") - fmt.Fprintf(os.Stderr, " %s --router --config /etc/logwisp/multi-pipeline.toml\n\n", os.Args[0]) - - fmt.Fprintf(os.Stderr, "Environment Variables:\n") - fmt.Fprintf(os.Stderr, " LOGWISP_CONFIG_FILE Config file path\n") - fmt.Fprintf(os.Stderr, " LOGWISP_CONFIG_DIR Config directory\n") - fmt.Fprintf(os.Stderr, " LOGWISP_DISABLE_STATUS_REPORTER Disable periodic status reports (set to 1)\n") - fmt.Fprintf(os.Stderr, " LOGWISP_BACKGROUND Internal use - background process marker\n") - fmt.Fprintf(os.Stderr, "\nFor complete documentation, see: https://github.com/logwisp/logwisp/tree/main/doc\n") -} \ No newline at end of file diff --git a/src/cmd/logwisp/main.go b/src/cmd/logwisp/main.go index 5622e7b..d1711ef 100644 --- a/src/cmd/logwisp/main.go +++ b/src/cmd/logwisp/main.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "os" + "os/exec" "os/signal" "strings" "syscall" @@ -19,62 +20,57 @@ import ( var logger *log.Logger func main() { - // Parse flags first to get quiet mode early - flagCfg, err := ParseFlags() + // Emulates nohup + signal.Ignore(syscall.SIGHUP) + + // Load configuration with automatic CLI parsing + cfg, err := config.Load(os.Args[1:]) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + 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 with quiet mode - InitOutputHandler(flagCfg.Quiet) + // Initialize output handler + InitOutputHandler(cfg.Quiet) - // Handle version flag - if flagCfg.ShowVersion { + // Handle version + if cfg.ShowVersion { fmt.Println(version.String()) os.Exit(0) } - // Handle background mode - if flagCfg.Background && !isBackgroundProcess() { - if err := runInBackground(); err != nil { + // Background mode spawns a child with internal --background-daemon flag. + if cfg.Background && !cfg.BackgroundDaemon { + // Prepare arguments for the child process, including originals and daemon flag. + args := append(os.Args[1:], "--background-daemon") + + cmd := exec.Command(os.Args[0], args...) + + if err := cmd.Start(); err != nil { FatalError(1, "Failed to start background process: %v\n", err) } - os.Exit(0) + + Print("Started LogWisp in background (PID: %d)\n", cmd.Process.Pid) + os.Exit(0) // The parent process exits successfully. } - // Set config file environment if specified - if flagCfg.ConfigFile != "" { - os.Setenv("LOGWISP_CONFIG_FILE", flagCfg.ConfigFile) - } - - // Load configuration with CLI overrides - cfg, err := config.LoadWithCLI(os.Args[1:], flagCfg) - if err != nil { - if flagCfg.ConfigFile != "" && strings.Contains(err.Error(), "not found") { - FatalError(2, "Config file not found: %s\n", flagCfg.ConfigFile) - } - FatalError(1, "Failed to load config: %v\n", err) - - } - - // DEBUG: Extra nil check - if cfg == nil { - FatalError(1, "Configuration is nil after loading\n") - } - - // Initialize logger with quiet mode awareness - if err := initializeLogger(cfg, flagCfg); err != nil { + // Initialize logger + if err := initializeLogger(cfg); err != nil { FatalError(1, "Failed to initialize logger: %v\n", err) } defer shutdownLogger() - // Log startup information (respects quiet mode via logger config) + // Log startup information logger.Info("msg", "LogWisp starting", "version", version.String(), - "config_file", flagCfg.ConfigFile, + "config_file", cfg.ConfigFile, "log_output", cfg.Logging.Output, - "router_mode", flagCfg.UseRouter) + "router_mode", cfg.UseRouter, + "background_mode", cfg.Background) // Create context for shutdown ctx, cancel := context.WithCancel(context.Background()) @@ -85,14 +81,14 @@ func main() { signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGKILL) // Bootstrap the service - svc, router, err := bootstrapService(ctx, cfg, flagCfg) + svc, router, err := bootstrapService(ctx, cfg) if err != nil { logger.Error("msg", "Failed to bootstrap service", "error", err) os.Exit(1) } // Start status reporter if enabled - if enableStatusReporter() { + if !cfg.DisableStatusReporter { go statusReporter(svc) } @@ -138,12 +134,4 @@ func shutdownLogger() { Error("Logger shutdown error: %v\n", err) } } -} - -func enableStatusReporter() bool { - // Status reporter can be disabled via environment variable - if os.Getenv("LOGWISP_DISABLE_STATUS_REPORTER") == "1" { - return false - } - return true } \ No newline at end of file diff --git a/src/cmd/logwisp/output.go b/src/cmd/logwisp/output.go index b3c670b..c7afdd9 100644 --- a/src/cmd/logwisp/output.go +++ b/src/cmd/logwisp/output.go @@ -29,7 +29,7 @@ func InitOutputHandler(quiet bool) { } // Print writes to stdout if not in quiet mode -func (o *OutputHandler) Print(format string, args ...interface{}) { +func (o *OutputHandler) Print(format string, args ...any) { o.mu.RLock() defer o.mu.RUnlock() @@ -39,7 +39,7 @@ func (o *OutputHandler) Print(format string, args ...interface{}) { } // Error writes to stderr if not in quiet mode -func (o *OutputHandler) Error(format string, args ...interface{}) { +func (o *OutputHandler) Error(format string, args ...any) { o.mu.RLock() defer o.mu.RUnlock() @@ -49,7 +49,7 @@ func (o *OutputHandler) Error(format string, args ...interface{}) { } // FatalError writes to stderr and exits (respects quiet mode) -func (o *OutputHandler) FatalError(code int, format string, args ...interface{}) { +func (o *OutputHandler) FatalError(code int, format string, args ...any) { o.Error(format, args...) os.Exit(code) } @@ -69,19 +69,19 @@ func (o *OutputHandler) SetQuiet(quiet bool) { } // Helper functions for global output handler -func Print(format string, args ...interface{}) { +func Print(format string, args ...any) { if output != nil { output.Print(format, args...) } } -func Error(format string, args ...interface{}) { +func Error(format string, args ...any) { if output != nil { output.Error(format, args...) } } -func FatalError(code int, format string, args ...interface{}) { +func FatalError(code int, format string, args ...any) { if output != nil { output.FatalError(code, format, args...) } else { diff --git a/src/internal/config/config.go b/src/internal/config/config.go index a4b901f..e5efc0d 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -2,10 +2,23 @@ package config type Config struct { - // Logging configuration - Logging *LogConfig `toml:"logging"` + // Top-level flags for application control + UseRouter bool `toml:"router"` + Background bool `toml:"background"` + ShowVersion bool `toml:"version"` + Quiet bool `toml:"quiet"` - // Pipeline configurations + // Runtime behavior flags + DisableStatusReporter bool `toml:"disable_status_reporter"` + + // Internal flag indicating demonized child process + BackgroundDaemon bool `toml:"background-daemon"` + + // Configuration file path + ConfigFile string `toml:"config"` + + // Existing fields + Logging *LogConfig `toml:"logging"` Pipelines []PipelineConfig `toml:"pipelines"` } diff --git a/src/internal/config/loader.go b/src/internal/config/loader.go index ade1feb..5161ccd 100644 --- a/src/internal/config/loader.go +++ b/src/internal/config/loader.go @@ -12,11 +12,24 @@ import ( // LoadContext holds all configuration sources type LoadContext struct { - FlagConfig interface{} // Parsed command-line flags from main + FlagConfig any // Parsed command-line flags from main } func defaults() *Config { return &Config{ + // Top-level flag defaults + UseRouter: false, + Background: false, + ShowVersion: false, + Quiet: false, + + // Runtime behavior defaults + DisableStatusReporter: false, + + // Child process indicator + BackgroundDaemon: false, + + // Existing defaults Logging: DefaultLogConfig(), Pipelines: []PipelineConfig{ { @@ -37,7 +50,7 @@ func defaults() *Config { Options: map[string]any{ "port": 8080, "buffer_size": 1000, - "stream_path": "/transport", + "stream_path": "/stream", "status_path": "/status", "heartbeat": map[string]any{ "enabled": true, @@ -54,16 +67,15 @@ func defaults() *Config { } } -// LoadWithCLI loads config with CLI flag overrides -func LoadWithCLI(cliArgs []string, flagCfg interface{}) (*Config, error) { - configPath := GetConfigPath() - +// Load is the single entry point for loading all configuration +func Load(args []string) (*Config, error) { + configPath, isExplicit := resolveConfigPath(args) // Build configuration with all sources cfg, err := lconfig.NewBuilder(). WithDefaults(defaults()). WithEnvPrefix("LOGWISP_"). WithFile(configPath). - WithArgs(cliArgs). + WithArgs(args). WithEnvTransform(customEnvTransform). WithSources( lconfig.SourceCLI, @@ -74,29 +86,25 @@ func LoadWithCLI(cliArgs []string, flagCfg interface{}) (*Config, error) { Build() if err != nil { - if strings.Contains(err.Error(), "not found") && configPath != "logwisp.toml" { - // If explicit config file specified and not found, fail - return nil, fmt.Errorf("config file not found: %s", configPath) - } - - if !strings.Contains(err.Error(), "not found") { + // Config file load errors + if strings.Contains(err.Error(), "not found") { + if isExplicit { + return nil, fmt.Errorf("config file not found: %s", configPath) + } + // If the default config file is not found, it's not an error. + } else { return nil, fmt.Errorf("failed to load config: %w", err) } } - // Likely never happens - if cfg == nil { - return nil, fmt.Errorf("configuration builder returned nil config") - } - + // Scan into final config struct finalConfig := &Config{} if err := cfg.Scan("", finalConfig); err != nil { return nil, fmt.Errorf("failed to scan config: %w", err) } - // Ensure we have valid config even with defaults - if finalConfig == nil { - return nil, fmt.Errorf("configuration scan produced nil config") + if _, err := os.Stat(configPath); err == nil { + finalConfig.ConfigFile = configPath } // Ensure critical fields are not nil @@ -104,14 +112,58 @@ func LoadWithCLI(cliArgs []string, flagCfg interface{}) (*Config, error) { finalConfig.Logging = DefaultLogConfig() } - // Apply any console target transformations here + // Apply console target overrides if needed if err := applyConsoleTargetOverrides(finalConfig); err != nil { return nil, fmt.Errorf("failed to apply console target overrides: %w", err) } + // Validate configuration return finalConfig, finalConfig.validate() } +// resolveConfigPath returns the configuration file path +func resolveConfigPath(args []string) (path string, isExplicit bool) { + // 1. Check for --config flag in command-line arguments (highest precedence) + for i, arg := range args { + if (arg == "--config" || arg == "-c") && i+1 < len(args) { + return args[i+1], true + } + if strings.HasPrefix(arg, "--config=") { + return strings.TrimPrefix(arg, "--config="), true + } + } + + // 2. Check environment variables + if configFile := os.Getenv("LOGWISP_CONFIG_FILE"); configFile != "" { + path = configFile + if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" { + path = filepath.Join(configDir, configFile) + } + return path, true + } + if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" { + return filepath.Join(configDir, "logwisp.toml"), true + } + + // 3. Check default user config location + if homeDir, err := os.UserHomeDir(); err == nil { + configPath := filepath.Join(homeDir, ".config", "logwisp", "logwisp.toml") + if _, err := os.Stat(configPath); err == nil { + return configPath, false // Found a default, but not explicitly set by user + } + } + + // 4. Fallback to default in current directory + return "logwisp.toml", false +} + +func customEnvTransform(path string) string { + env := strings.ReplaceAll(path, ".", "_") + env = strings.ToUpper(env) + env = "LOGWISP_" + env + return env +} + // applyConsoleTargetOverrides centralizes console target configuration func applyConsoleTargetOverrides(cfg *Config) error { // Check environment variable for console target override @@ -149,41 +201,4 @@ func applyConsoleTargetOverrides(cfg *Config) error { } return nil -} - -// GetConfigPath returns the configuration file path -func GetConfigPath() string { - // Check if explicit config file was specified via flag or env - if configFile := os.Getenv("LOGWISP_CONFIG_FILE"); configFile != "" { - if filepath.IsAbs(configFile) { - return configFile - } - if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" { - return filepath.Join(configDir, configFile) - } - return configFile - } - - if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" { - return filepath.Join(configDir, "logwisp.toml") - } - - // Default locations - if homeDir, err := os.UserHomeDir(); err == nil { - configPath := filepath.Join(homeDir, ".config", "logwisp.toml") - // Check if config exists in home directory - if _, err := os.Stat(configPath); err == nil { - return configPath - } - } - - // Return current directory default - return "logwisp.toml" -} - -func customEnvTransform(path string) string { - env := strings.ReplaceAll(path, ".", "_") - env = strings.ToUpper(env) - env = "LOGWISP_" + env - return env } \ No newline at end of file diff --git a/src/internal/source/file_watcher.go b/src/internal/source/file_watcher.go index 427b6fb..b2c4da9 100644 --- a/src/internal/source/file_watcher.go +++ b/src/internal/source/file_watcher.go @@ -79,11 +79,10 @@ func (w *fileWatcher) watch(ctx context.Context) error { } } +// FILE: src/internal/source/file_watcher.go func (w *fileWatcher) seekToEnd() error { file, err := os.Open(w.path) if err != nil { - // For non-existent files, initialize position to 0 - // This allows watching files that don't exist yet if os.IsNotExist(err) { w.mu.Lock() w.position = 0 @@ -103,13 +102,13 @@ func (w *fileWatcher) seekToEnd() error { } w.mu.Lock() - // Only seek to end if position was never set (-1) - // This preserves position = 0 for new files while allowing - // directory-discovered files to start reading from current position + defer w.mu.Unlock() + + // Keep existing position (including 0) + // First time initialization seeks to the end of the file if w.position == -1 { pos, err := file.Seek(0, io.SeekEnd) if err != nil { - w.mu.Unlock() return err } w.position = pos @@ -120,7 +119,6 @@ func (w *fileWatcher) seekToEnd() error { if stat, ok := info.Sys().(*syscall.Stat_t); ok { w.inode = stat.Ino } - w.mu.Unlock() return nil } @@ -171,35 +169,57 @@ func (w *fileWatcher) checkFile() error { w.inode = currentInode w.size = currentSize w.modTime = currentModTime - // Keep position at 0 to read from beginning if this is a new file - // or seek to end if we want to skip existing content - if oldSize == 0 && w.position == 0 { - // First time seeing this file, seek to end to skip existing content - w.position = currentSize - } + // Position stays at 0 for new files w.mu.Unlock() - return nil + // Don't return here - continue to read content } // Check for rotation rotated := false rotationReason := "" + startPos := oldPos - if oldInode != 0 && currentInode != 0 && currentInode != oldInode { - rotated = true - rotationReason = "inode change" - } else if currentSize < oldSize { + // Rotation detection + if currentSize < oldSize { + // File was truncated rotated = true rotationReason = "size decrease" } else if currentModTime.Before(oldModTime) && currentSize <= oldSize { + // Modification time went backwards (logrotate behavior) rotated = true rotationReason = "modification time reset" } else if oldPos > currentSize+1024 { + // Our position is way beyond file size rotated = true rotationReason = "position beyond file size" + } else if oldInode != 0 && currentInode != 0 && currentInode != oldInode { + // Inode changed - distinguish between rotation and atomic save + if currentSize == 0 { + // Empty file with new inode = likely rotation + rotated = true + rotationReason = "inode change with empty file" + } else if currentSize < oldPos { + // New file is smaller than our position = rotation + rotated = true + rotationReason = "inode change with size less than position" + } else { + // Inode changed but file has content and size >= position + // This is likely an atomic save by an editor + // Update inode but keep position + w.mu.Lock() + w.inode = currentInode + w.mu.Unlock() + + w.logger.Debug("msg", "Atomic file update detected", + "component", "file_watcher", + "path", w.path, + "old_inode", oldInode, + "new_inode", currentInode, + "position", oldPos, + "size", currentSize) + } } - startPos := oldPos if rotated { startPos = 0 w.mu.Lock()