From 379e7cc9904ddcf34ca9db0202c393b6a491f786 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sun, 26 Jul 2026 14:28:22 +0200 Subject: [PATCH] feat(applog): add JSON logger and context helpers --- backend/internal/applog/applog.go | 51 ++++++++++++++++++++++++ backend/internal/applog/applog_test.go | 54 ++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 backend/internal/applog/applog.go create mode 100644 backend/internal/applog/applog_test.go diff --git a/backend/internal/applog/applog.go b/backend/internal/applog/applog.go new file mode 100644 index 0000000..4d4b74c --- /dev/null +++ b/backend/internal/applog/applog.go @@ -0,0 +1,51 @@ +// Package applog provides geniusrun's structured JSON logging: a +// log/slog-based logger writing to stdout, plus context helpers so a +// logger enriched in one layer (e.g. internal/api's HTTP middleware, +// attaching a request_id) is picked up by another (e.g. internal/garmin's +// wrapper-call logging) without either package depending on the other. +package applog + +import ( + "context" + "io" + "log/slog" + "strings" +) + +// NewLogger builds a JSON-handler *slog.Logger writing to w at the given +// level ("debug"|"info"|"warn"|"error", case-insensitive; anything else +// defaults to info). +func NewLogger(level string, w io.Writer) *slog.Logger { + return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: parseLevel(level)})) +} + +func parseLevel(level string) slog.Level { + switch strings.ToLower(level) { + case "debug": + return slog.LevelDebug + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +type ctxKey struct{} + +// WithLogger returns a context carrying logger, retrievable via FromContext. +func WithLogger(ctx context.Context, logger *slog.Logger) context.Context { + return context.WithValue(ctx, ctxKey{}, logger) +} + +// FromContext returns the logger stashed by WithLogger, or slog.Default() +// if none was -- callers (e.g. the background incremental-sync loop, or +// tests that don't bother injecting one) always get a working logger, +// never nil. +func FromContext(ctx context.Context) *slog.Logger { + if logger, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok { + return logger + } + return slog.Default() +} diff --git a/backend/internal/applog/applog_test.go b/backend/internal/applog/applog_test.go new file mode 100644 index 0000000..a80289a --- /dev/null +++ b/backend/internal/applog/applog_test.go @@ -0,0 +1,54 @@ +package applog + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "strings" + "testing" +) + +func TestNewLogger_FiltersBelowConfiguredLevel(t *testing.T) { + var buf bytes.Buffer + logger := NewLogger("warn", &buf) + + logger.Info("should be dropped") + if buf.Len() != 0 { + t.Fatalf("expected no output for an Info message under a warn-level logger, got %q", buf.String()) + } + + logger.Warn("should appear") + if !strings.Contains(buf.String(), "should appear") { + t.Fatalf("expected the Warn message in output, got %q", buf.String()) + } +} + +func TestNewLogger_WritesValidJSON(t *testing.T) { + var buf bytes.Buffer + logger := NewLogger("info", &buf) + logger.Info("hello", "key", "value") + + var decoded map[string]any + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatalf("output is not valid JSON: %v (%q)", err, buf.String()) + } + if decoded["msg"] != "hello" || decoded["key"] != "value" { + t.Errorf("decoded = %+v, want msg=hello key=value", decoded) + } +} + +func TestWithLogger_FromContext_RoundTrip(t *testing.T) { + logger := slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil)) + ctx := WithLogger(context.Background(), logger) + + if got := FromContext(ctx); got != logger { + t.Errorf("FromContext returned a different logger than what was stashed") + } +} + +func TestFromContext_DefaultsWhenNoneSet(t *testing.T) { + if got := FromContext(context.Background()); got == nil { + t.Fatal("FromContext on a bare context returned nil, want slog.Default()") + } +}