Add structured logging with log/slog

Replace fmt.Printf logging calls with slog.Info/slog.Error for structured
JSON output to stderr. Add internal/log package to initialize the default
slog handler from the config log level. Fix .gitignore to only ignore the
binary at the repo root, not the cmd/eng-pad-server directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 20:52:22 -07:00
parent c5469c6bdf
commit a9e6ca022e
6 changed files with 145 additions and 5 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,4 @@
eng-pad-server /eng-pad-server
/srv/ /srv/
*.db *.db
*.db-wal *.db-wal

View File

@@ -0,0 +1,114 @@
package main
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"git.wntrmute.dev/kyle/eng-pad-server/internal/config"
"git.wntrmute.dev/kyle/eng-pad-server/internal/db"
"git.wntrmute.dev/kyle/eng-pad-server/internal/grpcserver"
applog "git.wntrmute.dev/kyle/eng-pad-server/internal/log"
"git.wntrmute.dev/kyle/eng-pad-server/internal/server"
"git.wntrmute.dev/kyle/eng-pad-server/internal/webserver"
"github.com/spf13/cobra"
)
var serverCmd = &cobra.Command{
Use: "server",
Short: "Start the eng-pad server",
RunE: runServer,
}
func init() {
rootCmd.AddCommand(serverCmd)
}
func runServer(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(cfgFile)
if err != nil {
return err
}
applog.Init(cfg.Log.Level)
database, err := db.Open(cfg.Database.Path)
if err != nil {
return err
}
defer func() { _ = database.Close() }()
if err := db.Migrate(database); err != nil {
return fmt.Errorf("migrate: %w", err)
}
slog.Info("eng-pad-server starting", "version", version)
// Start gRPC server
grpcSrv, err := grpcserver.Start(grpcserver.Config{
Addr: cfg.Server.GRPCAddr,
TLSCert: cfg.Server.TLSCert,
TLSKey: cfg.Server.TLSKey,
DB: database,
BaseURL: cfg.Web.BaseURL,
})
if err != nil {
return fmt.Errorf("start grpc: %w", err)
}
// Start REST API server
restSrv, err := server.Start(server.Config{
Addr: cfg.Server.ListenAddr,
TLSCert: cfg.Server.TLSCert,
TLSKey: cfg.Server.TLSKey,
DB: database,
BaseURL: cfg.Web.BaseURL,
})
if err != nil {
return fmt.Errorf("start rest: %w", err)
}
// Start Web UI server
webSrv, err := webserver.Start(webserver.Config{
Addr: cfg.Web.ListenAddr,
DB: database,
BaseURL: cfg.Web.BaseURL,
})
if err != nil {
return fmt.Errorf("start web: %w", err)
}
// Wait for shutdown signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
slog.Info("received shutdown signal", "signal", sig.String())
// Graceful shutdown with 30-second timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Stop gRPC server gracefully
grpcSrv.GracefulStop()
// Shutdown REST and web servers
if err := restSrv.Shutdown(ctx); err != nil {
slog.Error("REST server shutdown error", "error", err)
}
if err := webSrv.Shutdown(ctx); err != nil {
slog.Error("web server shutdown error", "error", err)
}
// Close the database
if err := database.Close(); err != nil {
slog.Error("database close error", "error", err)
}
slog.Info("shutdown complete")
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"crypto/tls" "crypto/tls"
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog"
"net" "net"
pb "git.wntrmute.dev/kyle/eng-pad-server/gen/engpad/v1" pb "git.wntrmute.dev/kyle/eng-pad-server/gen/engpad/v1"
@@ -46,7 +47,7 @@ func Start(cfg Config) (*grpc.Server, error) {
syncSvc := &SyncService{DB: cfg.DB, BaseURL: cfg.BaseURL} syncSvc := &SyncService{DB: cfg.DB, BaseURL: cfg.BaseURL}
pb.RegisterEngPadSyncServer(srv, syncSvc) pb.RegisterEngPadSyncServer(srv, syncSvc)
fmt.Printf("gRPC listening on %s\n", cfg.Addr) slog.Info("gRPC server started", "addr", cfg.Addr)
go func() { _ = srv.Serve(lis) }() go func() { _ = srv.Serve(lis) }()
return srv, nil return srv, nil

23
internal/log/log.go Normal file
View File

@@ -0,0 +1,23 @@
package log
import (
"log/slog"
"os"
"strings"
)
func Init(level string) {
var lvl slog.Level
switch strings.ToLower(level) {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})
slog.SetDefault(slog.New(handler))
}

View File

@@ -4,6 +4,7 @@ import (
"crypto/tls" "crypto/tls"
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"time" "time"
@@ -42,7 +43,7 @@ func Start(cfg Config) (*http.Server, error) {
IdleTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second,
} }
fmt.Printf("REST API listening on %s\n", cfg.Addr) slog.Info("REST API started", "addr", cfg.Addr)
go func() { _ = srv.ListenAndServeTLS("", "") }() go func() { _ = srv.ListenAndServeTLS("", "") }()
return srv, nil return srv, nil

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"io/fs" "io/fs"
"log/slog"
"net/http" "net/http"
"time" "time"
@@ -85,10 +86,10 @@ func Start(cfg Config) (*http.Server, error) {
Certificates: []tls.Certificate{tlsCert}, Certificates: []tls.Certificate{tlsCert},
MinVersion: tls.VersionTLS13, MinVersion: tls.VersionTLS13,
} }
fmt.Printf("Web UI listening on %s (TLS)\n", cfg.Addr) slog.Info("web UI started", "addr", cfg.Addr, "tls", true)
go func() { _ = srv.ListenAndServeTLS("", "") }() go func() { _ = srv.ListenAndServeTLS("", "") }()
} else { } else {
fmt.Printf("Web UI listening on %s\n", cfg.Addr) slog.Info("web UI started", "addr", cfg.Addr, "tls", false)
go func() { _ = srv.ListenAndServe() }() go func() { _ = srv.ListenAndServe() }()
} }