diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-14 10:07:52 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-14 10:07:52 +0300 |
| commit | 00a015a9642baee69def9a104602b4d59f980c63 (patch) | |
| tree | c3251e70b01af4eae273cf418279614a4f9c7bd6 /internal | |
| parent | 806ff16a0ad70ae2883a666bdc4158ba20ca4d4f (diff) | |
daemon: stdout-only slog logging for HTTP (task 43)
- Route http.Server ErrorLog and request access lines through slog text to
stdout (or Config.LogOutput for tests).
- Log daemon_listen on start and http_request per request (method, path,
status, duration_ms).
- CLI daemon: flag usage and missing-stats-dir message on stdout.
Made-with: Cursor
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cli/cli.go | 3 | ||||
| -rw-r--r-- | internal/daemon/daemon.go | 69 | ||||
| -rw-r--r-- | internal/daemon/daemon_test.go | 44 |
3 files changed, 107 insertions, 9 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0752806..7882758 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -135,13 +135,14 @@ func defaultListenFromEnv() string { func runDaemon(args []string) error { fs := flag.NewFlagSet("daemon", flag.ExitOnError) + fs.SetOutput(os.Stdout) statsDir := fs.String("stats-dir", os.Getenv("GOPRECORDS_STATS_DIR"), "Uptimed stats directory (required; env GOPRECORDS_STATS_DIR)") listen := fs.String("listen", defaultListenFromEnv(), "TCP listen address (env GOPRECORDS_LISTEN, default :8080)") if err := fs.Parse(args); err != nil { return err } if *statsDir == "" { - fmt.Fprintln(os.Stderr, "daemon: missing required flag: -stats-dir (or GOPRECORDS_STATS_DIR)") + fmt.Fprintln(os.Stdout, "daemon: missing required flag: -stats-dir (or GOPRECORDS_STATS_DIR)") fs.Usage() return fmt.Errorf("missing -stats-dir") } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a4b1a3e..a7de45f 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -4,27 +4,76 @@ import ( "bytes" "context" "fmt" + "io" + "log/slog" "net/http" + "os" "time" "codeberg.org/snonux/goprecords/internal/goprecords" ) -// Config holds daemon HTTP server settings. type Config struct { - StatsDir string - Addr string + StatsDir string + Addr string + LogOutput io.Writer } -// Handler returns an HTTP handler that serves health checks and reports from statsDir. -func Handler(statsDir string) http.Handler { +func routes(statsDir string) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/health", health) mux.HandleFunc("/report", report(statsDir)) return mux } -// Run starts a plain HTTP server until ctx is cancelled or ListenAndServe fails. +func Handler(statsDir string) http.Handler { + return routes(statsDir) +} + +func logWriter(cfg Config) io.Writer { + if cfg.LogOutput != nil { + return cfg.LogOutput + } + return os.Stdout +} + +func newDaemonLogger(w io.Writer) (*slog.Logger, slog.Handler) { + h := slog.NewTextHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo}) + return slog.New(h), h +} + +type statusRecorder struct { + http.ResponseWriter + code int +} + +func (r *statusRecorder) WriteHeader(status int) { + if r.code == 0 { + r.code = status + } + r.ResponseWriter.WriteHeader(status) +} + +func (r *statusRecorder) Write(b []byte) (int, error) { + if r.code == 0 { + r.code = http.StatusOK + } + return r.ResponseWriter.Write(b) +} + +func withAccessLog(log *slog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec := &statusRecorder{ResponseWriter: w} + start := time.Now() + next.ServeHTTP(rec, r) + code := rec.code + if code == 0 { + code = http.StatusOK + } + log.Info("http_request", "method", r.Method, "path", r.URL.Path, "status", code, "duration_ms", time.Since(start).Milliseconds()) + }) +} + func Run(ctx context.Context, cfg Config) error { if cfg.StatsDir == "" { return fmt.Errorf("stats directory is required") @@ -32,10 +81,14 @@ func Run(ctx context.Context, cfg Config) error { if cfg.Addr == "" { return fmt.Errorf("listen address is required") } + w := logWriter(cfg) + log, textHandler := newDaemonLogger(w) srv := &http.Server{ - Addr: cfg.Addr, - Handler: Handler(cfg.StatsDir), + Addr: cfg.Addr, + Handler: withAccessLog(log, routes(cfg.StatsDir)), + ErrorLog: slog.NewLogLogger(textHandler, slog.LevelError), } + log.Info("daemon_listen", "addr", cfg.Addr) errCh := make(chan error, 1) go func() { errCh <- srv.ListenAndServe() }() select { diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index b140c9e..9f25ca3 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -1,13 +1,16 @@ package daemon import ( + "bytes" "context" "io" + "log/slog" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" + "time" ) func TestHealth(t *testing.T) { @@ -131,3 +134,44 @@ func TestRunEmptyAddr(t *testing.T) { t.Fatal("expected error") } } + +func TestRunWritesDaemonListenToLogOutput(t *testing.T) { + var buf bytes.Buffer + ctx, cancel := context.WithCancel(context.Background()) + cfg := Config{StatsDir: t.TempDir(), Addr: "127.0.0.1:0", LogOutput: &buf} + done := make(chan struct{}) + go func() { + _ = Run(ctx, cfg) + close(done) + }() + deadline := time.After(2 * time.Second) + for !strings.Contains(buf.String(), "daemon_listen") { + select { + case <-deadline: + t.Fatalf("timeout waiting for daemon_listen, got %q", buf.String()) + case <-time.After(5 * time.Millisecond): + } + } + cancel() + <-done +} + +func TestAccessLogLineToWriter(t *testing.T) { + var buf bytes.Buffer + h := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}) + log := slog.New(h) + srv := httptest.NewServer(withAccessLog(log, routes(t.TempDir()))) + defer srv.Close() + res, err := http.Get(srv.URL + "/health") + if err != nil { + t.Fatal(err) + } + res.Body.Close() + body := buf.String() + if !strings.Contains(body, "http_request") || !strings.Contains(body, "method=GET") { + t.Fatalf("expected http_request line with method=GET, got %q", body) + } + if !strings.Contains(body, "path=/health") || !strings.Contains(body, "status=200") { + t.Fatalf("expected path and status in log, got %q", body) + } +} |
