summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-14 10:07:52 +0300
committerPaul Buetow <paul@buetow.org>2026-04-14 10:07:52 +0300
commit00a015a9642baee69def9a104602b4d59f980c63 (patch)
treec3251e70b01af4eae273cf418279614a4f9c7bd6 /internal
parent806ff16a0ad70ae2883a666bdc4158ba20ca4d4f (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.go3
-rw-r--r--internal/daemon/daemon.go69
-rw-r--r--internal/daemon/daemon_test.go44
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)
+ }
+}