diff --git a/.env.example b/.env.example index bf4a4d8..3a9ac3e 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,10 @@ DATABASE_URL=sqlite:///data/mindsift.db JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars BASE_URL=http://localhost:8080 ALLOW_REGISTRATION=false +# Logging level: debug, info, warn, error (Default: info) LOG_LEVEL=info +# LOG_LEVEL=debug +# DEBUG=true # Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI) SMTP_HOST= diff --git a/cmd/server/main.go b/cmd/server/main.go index b52aeb4..9f034c9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -4,7 +4,7 @@ import ( "context" "embed" "io/fs" - "log" + "log/slog" "net/http" "os" "os/signal" @@ -23,11 +23,16 @@ var embeddedDist embed.FS func main() { cfg := config.Load() - log.Printf("🧠 Starting MindSift Server on port %s (DB: %s)", cfg.Port, cfg.DatabasePath) + level := cfg.SlogLevel() + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) + slog.SetDefault(logger) + + slog.Info("MindSift Server starting", "port", cfg.Port, "log_level", level.String()) store, err := storage.NewStore(cfg.DatabasePath) if err != nil { - log.Fatalf("Failed to initialize storage: %v", err) + slog.Error("Failed to initialize storage", "error", err) + os.Exit(1) } defer store.Close() @@ -35,11 +40,11 @@ func main() { store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS) if cfg.DemoMode { - log.Printf("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...") + slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...") if err := store.EnsureDemoAccounts(); err != nil { - log.Printf("⚠️ Fehler beim Anlegen der Demo-Accounts: %v", err) + slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err) } else { - log.Printf("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)") + slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)") } } @@ -47,7 +52,8 @@ func main() { staticFS, err := fs.Sub(embeddedDist, "dist") if err != nil { - log.Fatalf("Failed to mount embedded dist filesystem: %v", err) + slog.Error("Failed to mount embedded dist filesystem", "error", err) + os.Exit(1) } srv := api.NewServer(cfg, store, wsHub, staticFS) @@ -61,9 +67,10 @@ func main() { } go func() { - log.Printf("✨ MindSift ready at http://0.0.0.0:%s", cfg.Port) + slog.Info("✨ MindSift ready", "url", "http://0.0.0.0:"+cfg.Port) if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("HTTP server error: %v", err) + slog.Error("HTTP server error", "error", err) + os.Exit(1) } }() @@ -71,7 +78,7 @@ func main() { signal.Notify(stop, os.Interrupt, syscall.SIGTERM) <-stop - log.Println("Shutting down MindSift server gracefully...") + slog.Info("Shutting down MindSift server gracefully...") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = httpServer.Shutdown(ctx) diff --git a/compose.yaml b/compose.yaml index fc22f8d..c57c511 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,6 +14,9 @@ services: - BASE_URL=${BASE_URL:-http://localhost:8080} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - LOG_LEVEL=${LOG_LEVEL:-info} + # Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging + # - LOG_LEVEL=debug + # - DEBUG=true # Demo mode: if true, automatically seeds demo accounts admin/admin and user/user - MINDA_DEMO=${MINDA_DEMO:-false} # Optional SMTP / Mail Configuration (can also be configured in Admin UI) diff --git a/docker-compose.yml b/docker-compose.yml index fc22f8d..c57c511 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,9 @@ services: - BASE_URL=${BASE_URL:-http://localhost:8080} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - LOG_LEVEL=${LOG_LEVEL:-info} + # Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging + # - LOG_LEVEL=debug + # - DEBUG=true # Demo mode: if true, automatically seeds demo accounts admin/admin and user/user - MINDA_DEMO=${MINDA_DEMO:-false} # Optional SMTP / Mail Configuration (can also be configured in Admin UI) diff --git a/internal/api/server.go b/internal/api/server.go index e5e0f57..00bda7a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "io/fs" + "log/slog" "net/http" "net/smtp" "strings" @@ -48,6 +49,7 @@ func (s *Server) Router() http.Handler { r.Use(middleware.RealIP) r.Use(middleware.Recoverer) + r.Use(s.apiLoggingMiddleware) r.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, @@ -239,6 +241,54 @@ func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) } +func (s *Server) apiLoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only log API requests (/api/...) + if !strings.HasPrefix(r.URL.Path, "/api") { + next.ServeHTTP(w, r) + return + } + + start := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + next.ServeHTTP(ww, r) + + duration := time.Since(start) + durationMs := float64(duration.Microseconds()) / 1000.0 + status := ww.Status() + if status == 0 { + status = http.StatusOK + } + + method := r.Method + path := r.URL.Path + + if status >= 500 { + slog.Error("HTTP request failed", + "method", method, + "path", path, + "status", status, + "duration_ms", durationMs, + ) + } else if status >= 400 { + slog.Warn("HTTP request warning", + "method", method, + "path", path, + "status", status, + "duration_ms", durationMs, + ) + } else { + slog.Debug("HTTP request completed", + "method", method, + "path", path, + "status", status, + "duration_ms", durationMs, + ) + } + }) +} + func (s *Server) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 1. Check Authorization Bearer Token diff --git a/internal/config/config.go b/internal/config/config.go index e9a3668..0eab1c4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "log/slog" "os" "path/filepath" "strings" @@ -14,6 +15,7 @@ type Config struct { BaseURL string AllowRegistration bool LogLevel string + Debug bool DemoMode bool // Optional SMTP environment variables (can also be overridden/configured in DB system_settings) @@ -40,7 +42,14 @@ func Load() *Config { jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026") baseURL := getEnv("BASE_URL", "http://localhost:"+port) allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true" - logLevel := getEnv("LOG_LEVEL", "info") + + debugVal := strings.ToLower(getEnv("DEBUG", "false")) + debug := debugVal == "true" || debugVal == "1" || debugVal == "yes" || debugVal == "on" + + logLevel := strings.ToLower(getEnv("LOG_LEVEL", "info")) + if debug { + logLevel = "debug" + } demoVal := strings.ToLower(getEnv("MINDA_DEMO", getEnv("MINDSIFT_DEMO", getEnv("DEMO_MODE", "false")))) demoMode := demoVal == "true" || demoVal == "1" || demoVal == "yes" || demoVal == "on" @@ -60,6 +69,7 @@ func Load() *Config { BaseURL: baseURL, AllowRegistration: allowReg, LogLevel: logLevel, + Debug: debug, DemoMode: demoMode, SMTPHost: smtpHost, SMTPPort: smtpPort, @@ -70,6 +80,20 @@ func Load() *Config { } } +// SlogLevel parses Config.LogLevel into a standard slog.Level. +func (c *Config) SlogLevel() slog.Level { + switch strings.ToLower(strings.TrimSpace(c.LogLevel)) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + func getEnv(key, fallback string) string { if val, ok := os.LookupEnv(key); ok && strings.TrimSpace(val) != "" { return strings.TrimSpace(val) diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..af25526 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,58 @@ +package config + +import ( + "log/slog" + "os" + "testing" +) + +func TestConfigLogLevelAndDebug(t *testing.T) { + os.Setenv("PORT", "9090") + defer os.Unsetenv("PORT") + + // 1. Default info + os.Unsetenv("LOG_LEVEL") + os.Unsetenv("DEBUG") + cfg := Load() + if cfg.SlogLevel() != slog.LevelInfo { + t.Fatalf("expected LevelInfo, got %v", cfg.SlogLevel()) + } + + // 2. LOG_LEVEL=debug + os.Setenv("LOG_LEVEL", "debug") + cfg = Load() + if cfg.SlogLevel() != slog.LevelDebug { + t.Fatalf("expected LevelDebug, got %v", cfg.SlogLevel()) + } + + // 3. LOG_LEVEL=warn + os.Setenv("LOG_LEVEL", "warn") + cfg = Load() + if cfg.SlogLevel() != slog.LevelWarn { + t.Fatalf("expected LevelWarn, got %v", cfg.SlogLevel()) + } + + // 4. LOG_LEVEL=error + os.Setenv("LOG_LEVEL", "error") + cfg = Load() + if cfg.SlogLevel() != slog.LevelError { + t.Fatalf("expected LevelError, got %v", cfg.SlogLevel()) + } + + // 5. DEBUG=true overrides LOG_LEVEL + os.Setenv("LOG_LEVEL", "error") + os.Setenv("DEBUG", "true") + cfg = Load() + if cfg.SlogLevel() != slog.LevelDebug { + t.Fatalf("expected LevelDebug when DEBUG=true, got %v", cfg.SlogLevel()) + } + + // 6. DEBUG=1 overrides LOG_LEVEL + os.Setenv("DEBUG", "1") + cfg = Load() + if cfg.SlogLevel() != slog.LevelDebug { + t.Fatalf("expected LevelDebug when DEBUG=1, got %v", cfg.SlogLevel()) + } + os.Unsetenv("DEBUG") + os.Unsetenv("LOG_LEVEL") +} diff --git a/internal/ws/docroom.go b/internal/ws/docroom.go index 84c2540..099e28b 100644 --- a/internal/ws/docroom.go +++ b/internal/ws/docroom.go @@ -2,7 +2,7 @@ package ws import ( "encoding/json" - "log" + "log/slog" "net/http" "sync" "time" @@ -477,13 +477,30 @@ func (r *DocRoom) Flush() { r.dirty = false r.mu.Unlock() + tagsCount := 0 + for _, b := range blocksCopy { + tagsCount += len(domain.ExtractTags(b.Content)) + } + + start := time.Now() r.hub.mu.RLock() store := r.hub.docStore r.hub.mu.RUnlock() if store != nil { if err := store.FlushDocumentState(docID, title, blocksCopy); err != nil { - log.Printf("[doc-room] flush error for doc %s: %v", docID, err) + slog.Error("SQLite document flush failed", + "doc_id", docID, + "error", err, + ) + } else { + durationMs := float64(time.Since(start).Microseconds()) / 1000.0 + slog.Debug("SQLite document flushed", + "doc_id", docID, + "blocks_count", len(blocksCopy), + "tags_count", tagsCount, + "duration_ms", durationMs, + ) } } } @@ -492,7 +509,7 @@ func (r *DocRoom) Flush() { func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string, clientID string, canWrite bool) { conn, err := upgrader.Upgrade(w, req, nil) if err != nil { - log.Printf("[doc-ws] upgrade error for doc %s: %v", r.docID, err) + slog.Error("WebSocket upgrade error", "doc_id", r.docID, "error", err) return } @@ -518,6 +535,7 @@ func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID st r.mu.Lock() r.wsClients[client] = true + activeClients := len(r.wsClients) if r.activeCursors == nil { r.activeCursors = make(map[string]*CursorPresence) } @@ -542,6 +560,13 @@ func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID st } r.mu.Unlock() + slog.Debug("WS client joined doc room", + "doc_id", r.docID, + "client_id", clientID, + "user_id", userID, + "active_clients", activeClients, + ) + // Immediately send live in-memory state and current cursors to the newly connected client initEvt := DocSyncEvent{ Type: "doc_state", @@ -609,6 +634,13 @@ func (c *DocWSClient) readPump() { } c.room.mu.Unlock() + slog.Debug("WS client left doc room", + "doc_id", c.room.docID, + "client_id", c.clientID, + "user_id", c.userID, + "active_clients", remaining, + ) + _ = c.conn.Close() // Flush immediately when the last client leaves the document room @@ -632,6 +664,18 @@ func (c *DocWSClient) readPump() { var evt DocSyncEvent if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" { + targetBlockID := evt.BlockID + if targetBlockID == "" && evt.Block != nil { + targetBlockID = evt.Block.ID + } + + slog.Debug("WS doc event received", + "type", evt.Type, + "doc_id", c.room.docID, + "block_id", targetBlockID, + "sender_client_id", c.clientID, + ) + // Read-only clients can still move their cursor and send presence updates if !c.canWrite && evt.Type != "cursor_moved" { continue diff --git a/internal/ws/hub.go b/internal/ws/hub.go index aa5dd27..bb51fab 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -3,7 +3,7 @@ package ws import ( "encoding/json" "fmt" - "log" + "log/slog" "net/http" "strings" "sync" @@ -627,7 +627,7 @@ func (c *Client) SendEvent(event string, payload interface{}) { func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID string) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { - log.Printf("[ws] upgrade error: %v", err) + slog.Error("WebSocket upgrade error", "room_id", roomID, "user_id", userID, "error", err) return }