feat(logging): add file-based session logging for GUI and CLI
- commit
- 4b325e345800d6dca4e8b07e331c33c5f1dc1cba
- author
- bnema · · 2025-12-17 20:45 UTC
- parent
- 99cf04e
- signature
- unsigned
2 files · +111 -4
M cmd/dumber/main.go +48 -4
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/assets" | |
| 11 | 11 | "github.com/bnema/dumber/internal/application/usecase" | |
| 12 | + | "github.com/bnema/dumber/internal/cli/cmd" | |
| 12 | 13 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 13 | 14 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 14 | 15 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| @@ -24,7 +25,27 @@ var ( | |||
| 24 | 25 | buildDate = "unknown" | |
| 25 | 26 | ) | |
| 26 | 27 | ||
| 28 | + | // initialURL holds the URL to open on startup (from browse command). | |
| 29 | + | var initialURL string | |
| 30 | + | ||
| 27 | 31 | func main() { | |
| 32 | + | // Run GUI mode for browse command | |
| 33 | + | if len(os.Args) > 1 && os.Args[1] == "browse" { | |
| 34 | + | // Extract URL if provided | |
| 35 | + | if len(os.Args) > 2 { | |
| 36 | + | initialURL = os.Args[2] | |
| 37 | + | } | |
| 38 | + | // Strip "browse" and URL from args so GTK doesn't see them | |
| 39 | + | os.Args = os.Args[:1] | |
| 40 | + | runGUI() | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Default: run CLI (shows help if no subcommand) | |
| 45 | + | cmd.Execute() | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func runGUI() { | |
| 28 | 49 | // GTK requires all GTK calls to be made from the main thread | |
| 29 | 50 | runtime.LockOSThread() | |
| 30 | 51 | ||
| @@ -36,8 +57,30 @@ func main() { | |||
| 36 | 57 | ||
| 37 | 58 | cfg := config.Get() | |
| 38 | 59 | ||
| 39 | - | // Initialize logger from config | |
| 40 | - | logger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format) | |
| 60 | + | // Generate session ID for this browser run | |
| 61 | + | sessionID := logging.GenerateSessionID() | |
| 62 | + | ||
| 63 | + | // Initialize logger with session file output | |
| 64 | + | logger, logCleanup, err := logging.NewWithFile( | |
| 65 | + | logging.Config{ | |
| 66 | + | Level: logging.ParseLevel(cfg.Logging.Level), | |
| 67 | + | Format: cfg.Logging.Format, | |
| 68 | + | TimeFormat: "15:04:05", | |
| 69 | + | }, | |
| 70 | + | logging.FileConfig{ | |
| 71 | + | Enabled: cfg.Logging.EnableFileLog, | |
| 72 | + | LogDir: cfg.Logging.LogDir, | |
| 73 | + | SessionID: sessionID, | |
| 74 | + | }, | |
| 75 | + | ) | |
| 76 | + | if err != nil { | |
| 77 | + | // Fall back to stderr-only logger | |
| 78 | + | logger = logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format) | |
| 79 | + | fmt.Fprintf(os.Stderr, "Warning: failed to create session log file: %v\n", err) | |
| 80 | + | } else { | |
| 81 | + | defer logCleanup() | |
| 82 | + | } | |
| 83 | + | ||
| 41 | 84 | logger.Info(). | |
| 42 | 85 | Str("version", version). | |
| 43 | 86 | Str("commit", commit). | |
| @@ -61,8 +104,8 @@ func main() { | |||
| 61 | 104 | logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory") | |
| 62 | 105 | } | |
| 63 | 106 | ||
| 64 | - | // Initialize SQLite database | |
| 65 | - | dbPath := filepath.Join(stateDir, "dumber.db") | |
| 107 | + | // Initialize SQLite database (in data dir per XDG spec - user data) | |
| 108 | + | dbPath := filepath.Join(dataDir, "dumber.db") | |
| 66 | 109 | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 67 | 110 | if err != nil { | |
| 68 | 111 | logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database") | |
| @@ -113,6 +156,7 @@ func main() { | |||
| 113 | 156 | deps := &ui.Dependencies{ | |
| 114 | 157 | Ctx: ctx, | |
| 115 | 158 | Config: cfg, | |
| 159 | + | InitialURL: initialURL, | |
| 116 | 160 | Theme: themeManager, | |
| 117 | 161 | WebContext: wkCtx, | |
| 118 | 162 | Pool: pool, | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | "github.com/bnema/dumber/assets" | 10 | "github.com/bnema/dumber/assets" | ||
| 11 | "github.com/bnema/dumber/internal/application/usecase" | 11 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 12 | + | "github.com/bnema/dumber/internal/cli/cmd" | |||
| 12 | "github.com/bnema/dumber/internal/infrastructure/config" | 13 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 13 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | 14 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | ||
| 14 | "github.com/bnema/dumber/internal/infrastructure/webkit" | 15 | "github.com/bnema/dumber/internal/infrastructure/webkit" | ||
| @@ -24,7 +25,27 @@ var ( | |||||
| 24 | buildDate = "unknown" | 25 | buildDate = "unknown" | ||
| 25 | ) | 26 | ) | ||
| 26 | 27 | ||||
| 28 | + | // initialURL holds the URL to open on startup (from browse command). | |||
| 29 | + | var initialURL string | |||
| 30 | + | ||||
| 27 | func main() { | 31 | func main() { | ||
| 32 | + | // Run GUI mode for browse command | |||
| 33 | + | if len(os.Args) > 1 && os.Args[1] == "browse" { | |||
| 34 | + | // Extract URL if provided | |||
| 35 | + | if len(os.Args) > 2 { | |||
| 36 | + | initialURL = os.Args[2] | |||
| 37 | + | } | |||
| 38 | + | // Strip "browse" and URL from args so GTK doesn't see them | |||
| 39 | + | os.Args = os.Args[:1] | |||
| 40 | + | runGUI() | |||
| 41 | + | return | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // Default: run CLI (shows help if no subcommand) | |||
| 45 | + | cmd.Execute() | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func runGUI() { | |||
| 28 | // GTK requires all GTK calls to be made from the main thread | 49 | // GTK requires all GTK calls to be made from the main thread | ||
| 29 | runtime.LockOSThread() | 50 | runtime.LockOSThread() | ||
| 30 | 51 | ||||
| @@ -36,8 +57,30 @@ func main() { | |||||
| 36 | 57 | ||||
| 37 | cfg := config.Get() | 58 | cfg := config.Get() | ||
| 38 | 59 | ||||
| 39 | - | // Initialize logger from config | 60 | + | // Generate session ID for this browser run |
| 40 | - | logger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format) | 61 | + | sessionID := logging.GenerateSessionID() |
| 62 | + | ||||
| 63 | + | // Initialize logger with session file output | |||
| 64 | + | logger, logCleanup, err := logging.NewWithFile( | |||
| 65 | + | logging.Config{ | |||
| 66 | + | Level: logging.ParseLevel(cfg.Logging.Level), | |||
| 67 | + | Format: cfg.Logging.Format, | |||
| 68 | + | TimeFormat: "15:04:05", | |||
| 69 | + | }, | |||
| 70 | + | logging.FileConfig{ | |||
| 71 | + | Enabled: cfg.Logging.EnableFileLog, | |||
| 72 | + | LogDir: cfg.Logging.LogDir, | |||
| 73 | + | SessionID: sessionID, | |||
| 74 | + | }, | |||
| 75 | + | ) | |||
| 76 | + | if err != nil { | |||
| 77 | + | // Fall back to stderr-only logger | |||
| 78 | + | logger = logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format) | |||
| 79 | + | fmt.Fprintf(os.Stderr, "Warning: failed to create session log file: %v\n", err) | |||
| 80 | + | } else { | |||
| 81 | + | defer logCleanup() | |||
| 82 | + | } | |||
| 83 | + | ||||
| 41 | logger.Info(). | 84 | logger.Info(). | ||
| 42 | Str("version", version). | 85 | Str("version", version). | ||
| 43 | Str("commit", commit). | 86 | Str("commit", commit). | ||
| @@ -61,8 +104,8 @@ func main() { | |||||
| 61 | logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory") | 104 | logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory") | ||
| 62 | } | 105 | } | ||
| 63 | 106 | ||||
| 64 | - | // Initialize SQLite database | 107 | + | // Initialize SQLite database (in data dir per XDG spec - user data) |
| 65 | - | dbPath := filepath.Join(stateDir, "dumber.db") | 108 | + | dbPath := filepath.Join(dataDir, "dumber.db") |
| 66 | db, err := sqlite.NewConnection(ctx, dbPath) | 109 | db, err := sqlite.NewConnection(ctx, dbPath) | ||
| 67 | if err != nil { | 110 | if err != nil { | ||
| 68 | logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database") | 111 | logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database") | ||
| @@ -113,6 +156,7 @@ func main() { | |||||
| 113 | deps := &ui.Dependencies{ | 156 | deps := &ui.Dependencies{ | ||
| 114 | Ctx: ctx, | 157 | Ctx: ctx, | ||
| 115 | Config: cfg, | 158 | Config: cfg, | ||
| 159 | + | InitialURL: initialURL, | |||
| 116 | Theme: themeManager, | 160 | Theme: themeManager, | ||
| 117 | WebContext: wkCtx, | 161 | WebContext: wkCtx, | ||
| 118 | Pool: pool, | 162 | Pool: pool, | ||
M internal/logging/logger.go +63 -0
| @@ -3,6 +3,7 @@ package logging | |||
| 3 | 3 | import ( | |
| 4 | 4 | "io" | |
| 5 | 5 | "os" | |
| 6 | + | "path/filepath" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/rs/zerolog" | |
| @@ -15,6 +16,13 @@ type Config struct { | |||
| 15 | 16 | TimeFormat string | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 19 | + | // FileConfig holds file logging configuration | |
| 20 | + | type FileConfig struct { | |
| 21 | + | Enabled bool | |
| 22 | + | LogDir string | |
| 23 | + | SessionID string | |
| 24 | + | } | |
| 25 | + | ||
| 18 | 26 | // DefaultConfig returns sensible defaults | |
| 19 | 27 | func DefaultConfig() Config { | |
| 20 | 28 | return Config{ | |
| @@ -46,6 +54,61 @@ func New(cfg Config) zerolog.Logger { | |||
| 46 | 54 | Logger() | |
| 47 | 55 | } | |
| 48 | 56 | ||
| 57 | + | // NewWithFile creates a logger that writes to both stderr and a session log file. | |
| 58 | + | // The session file uses JSON format for easy parsing by the CLI logs command. | |
| 59 | + | // LogDir must exist before calling this function (handled by config.EnsureDirectories). | |
| 60 | + | // Returns the logger and a cleanup function to close the file. | |
| 61 | + | func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) { | |
| 62 | + | var writers []io.Writer | |
| 63 | + | var cleanup func() = func() {} | |
| 64 | + | ||
| 65 | + | // Console output (stderr) | |
| 66 | + | switch cfg.Format { | |
| 67 | + | case "console", "text", "": | |
| 68 | + | writers = append(writers, zerolog.ConsoleWriter{ | |
| 69 | + | Out: os.Stderr, | |
| 70 | + | TimeFormat: cfg.TimeFormat, | |
| 71 | + | }) | |
| 72 | + | case "json": | |
| 73 | + | writers = append(writers, os.Stderr) | |
| 74 | + | default: | |
| 75 | + | writers = append(writers, zerolog.ConsoleWriter{ | |
| 76 | + | Out: os.Stderr, | |
| 77 | + | TimeFormat: cfg.TimeFormat, | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Session file output (JSON format for parsing) | |
| 82 | + | if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" { | |
| 83 | + | // Ensure log directory exists (LogDir is from config, may not have logs subdir) | |
| 84 | + | if err := os.MkdirAll(fileCfg.LogDir, 0755); err != nil { | |
| 85 | + | return zerolog.Logger{}, nil, err | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID)) | |
| 89 | + | file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) | |
| 90 | + | if err != nil { | |
| 91 | + | return zerolog.Logger{}, nil, err | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | writers = append(writers, file) | |
| 95 | + | cleanup = func() { file.Close() } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | multi := zerolog.MultiLevelWriter(writers...) | |
| 99 | + | ctx := zerolog.New(multi). | |
| 100 | + | Level(cfg.Level). | |
| 101 | + | With(). | |
| 102 | + | Timestamp() | |
| 103 | + | ||
| 104 | + | // Add session ID to all log entries if provided | |
| 105 | + | if fileCfg.SessionID != "" { | |
| 106 | + | ctx = ctx.Str("session", fileCfg.SessionID) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | return ctx.Logger(), cleanup, nil | |
| 110 | + | } | |
| 111 | + | ||
| 49 | 112 | // NewFromEnv creates a logger based on environment variables | |
| 50 | 113 | // DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info) | |
| 51 | 114 | // DUMBER_LOG_FORMAT: json, console (default: console) | |
| @@ -3,6 +3,7 @@ package logging | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "io" | 4 | "io" | ||
| 5 | "os" | 5 | "os" | ||
| 6 | + | "path/filepath" | |||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| 8 | "github.com/rs/zerolog" | 9 | "github.com/rs/zerolog" | ||
| @@ -15,6 +16,13 @@ type Config struct { | |||||
| 15 | TimeFormat string | 16 | TimeFormat string | ||
| 16 | } | 17 | } | ||
| 17 | 18 | ||||
| 19 | + | // FileConfig holds file logging configuration | |||
| 20 | + | type FileConfig struct { | |||
| 21 | + | Enabled bool | |||
| 22 | + | LogDir string | |||
| 23 | + | SessionID string | |||
| 24 | + | } | |||
| 25 | + | ||||
| 18 | // DefaultConfig returns sensible defaults | 26 | // DefaultConfig returns sensible defaults | ||
| 19 | func DefaultConfig() Config { | 27 | func DefaultConfig() Config { | ||
| 20 | return Config{ | 28 | return Config{ | ||
| @@ -46,6 +54,61 @@ func New(cfg Config) zerolog.Logger { | |||||
| 46 | Logger() | 54 | Logger() | ||
| 47 | } | 55 | } | ||
| 48 | 56 | ||||
| 57 | + | // NewWithFile creates a logger that writes to both stderr and a session log file. | |||
| 58 | + | // The session file uses JSON format for easy parsing by the CLI logs command. | |||
| 59 | + | // LogDir must exist before calling this function (handled by config.EnsureDirectories). | |||
| 60 | + | // Returns the logger and a cleanup function to close the file. | |||
| 61 | + | func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) { | |||
| 62 | + | var writers []io.Writer | |||
| 63 | + | var cleanup func() = func() {} | |||
| 64 | + | ||||
| 65 | + | // Console output (stderr) | |||
| 66 | + | switch cfg.Format { | |||
| 67 | + | case "console", "text", "": | |||
| 68 | + | writers = append(writers, zerolog.ConsoleWriter{ | |||
| 69 | + | Out: os.Stderr, | |||
| 70 | + | TimeFormat: cfg.TimeFormat, | |||
| 71 | + | }) | |||
| 72 | + | case "json": | |||
| 73 | + | writers = append(writers, os.Stderr) | |||
| 74 | + | default: | |||
| 75 | + | writers = append(writers, zerolog.ConsoleWriter{ | |||
| 76 | + | Out: os.Stderr, | |||
| 77 | + | TimeFormat: cfg.TimeFormat, | |||
| 78 | + | }) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Session file output (JSON format for parsing) | |||
| 82 | + | if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" { | |||
| 83 | + | // Ensure log directory exists (LogDir is from config, may not have logs subdir) | |||
| 84 | + | if err := os.MkdirAll(fileCfg.LogDir, 0755); err != nil { | |||
| 85 | + | return zerolog.Logger{}, nil, err | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID)) | |||
| 89 | + | file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) | |||
| 90 | + | if err != nil { | |||
| 91 | + | return zerolog.Logger{}, nil, err | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | writers = append(writers, file) | |||
| 95 | + | cleanup = func() { file.Close() } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | multi := zerolog.MultiLevelWriter(writers...) | |||
| 99 | + | ctx := zerolog.New(multi). | |||
| 100 | + | Level(cfg.Level). | |||
| 101 | + | With(). | |||
| 102 | + | Timestamp() | |||
| 103 | + | ||||
| 104 | + | // Add session ID to all log entries if provided | |||
| 105 | + | if fileCfg.SessionID != "" { | |||
| 106 | + | ctx = ctx.Str("session", fileCfg.SessionID) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | return ctx.Logger(), cleanup, nil | |||
| 110 | + | } | |||
| 111 | + | ||||
| 49 | // NewFromEnv creates a logger based on environment variables | 112 | // NewFromEnv creates a logger based on environment variables | ||
| 50 | // DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info) | 113 | // DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info) | ||
| 51 | // DUMBER_LOG_FORMAT: json, console (default: console) | 114 | // DUMBER_LOG_FORMAT: json, console (default: console) | ||