refactor(log): unified schema — mandatory type/class/method, request_id removed

Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 19:45:30 +02:00
parent 78c494affb
commit 6effb79097
16 changed files with 267 additions and 152 deletions

View File

@@ -93,12 +93,12 @@ func main() {
outPath := "../docs/DATABASE.md" outPath := "../docs/DATABASE.md"
must(os.WriteFile(outPath, []byte(b.String()), 0644)) must(os.WriteFile(outPath, []byte(b.String()), 0644))
slog.Info("wrote schema doc", "path", outPath) applog.App("main", "main").Info("wrote schema doc", "path", outPath)
} }
func must(err error) { func must(err error) {
if err != nil { if err != nil {
slog.Error("dumpschema", "error", err) applog.App("main", "must").Error("dumpschema failed", "error", err)
os.Exit(1) os.Exit(1)
} }
} }

View File

@@ -24,14 +24,14 @@ import (
// structured replacement for log.Fatalf, so even startup failures come // structured replacement for log.Fatalf, so even startup failures come
// out as JSON lines. // out as JSON lines.
func fatal(msg string, err error) { func fatal(msg string, err error) {
slog.Error(msg, "error", err) applog.App("main", "fatal").Error(msg, "error", err)
os.Exit(1) os.Exit(1)
} }
func main() { func main() {
// Bootstrap logger at info so failures loading the env config itself // Bootstrap logger at info so failures loading the env config itself
// (which carries the real log level) are still emitted as JSON. // (which carries the real log level) are still emitted as JSON.
slog.SetDefault(applog.NewLogger("info", os.Stdout)) slog.SetDefault(applog.NewLogger("warn", os.Stdout))
envCfg, err := config.LoadEnv() envCfg, err := config.LoadEnv()
if err != nil { if err != nil {
@@ -103,17 +103,17 @@ func main() {
httpServer := &http.Server{Addr: envCfg.BackendAddr, Handler: server.Router()} httpServer := &http.Server{Addr: envCfg.BackendAddr, Handler: server.Router()}
go func() { go func() {
slog.Info("geniusrund listening", "addr", envCfg.BackendAddr) applog.App("main", "main").Info("geniusrund listening", "addr", envCfg.BackendAddr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fatal("http server", err) fatal("http server", err)
} }
}() }()
<-ctx.Done() <-ctx.Done()
slog.Info("shutting down") applog.App("main", "main").Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil { if err := httpServer.Shutdown(shutdownCtx); err != nil {
slog.Error("http server shutdown", "error", err) applog.App("main", "main").Error("http server shutdown", "error", err)
} }
} }

View File

@@ -243,7 +243,7 @@ func must(err error) {
// fatal logs through the application JSON logger and exits -- the // fatal logs through the application JSON logger and exits -- the
// structured replacement for log.Fatal. // structured replacement for log.Fatal.
func fatal(msg string, err error) { func fatal(msg string, err error) {
slog.Error(msg, "error", err) applog.App("main", "fatal").Error(msg, "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -255,7 +255,7 @@ func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string
return k.ID return k.ID
} }
} }
slog.Error("no workout kind with that name for the seeded user (did ProvisionUser seed the taxonomy?)", "kind", name) applog.App("main", "mustFindKindID").Error("no workout kind with that name for the seeded user (did ProvisionUser seed the taxonomy?)", "kind", name)
os.Exit(1) os.Exit(1)
return 0 return 0
} }

View File

@@ -1146,10 +1146,11 @@ func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) {
func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) { func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
s, _, _ := newTestServer(t) s, _, _ := newTestServer(t)
var buf bytes.Buffer var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil)) prev := slog.Default()
slog.SetDefault(applog.NewLogger("info", &buf))
defer slog.SetDefault(prev)
req := httptest.NewRequest(http.MethodGet, "/api/health", nil) req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
req = req.WithContext(applog.WithLogger(req.Context(), logger))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req) s.Router().ServeHTTP(rec, req)
@@ -1157,11 +1158,14 @@ func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil { if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String()) t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
} }
if entry["msg"] != "HTTP request" { if entry["type"] != "http" || entry["class"] != "api" || entry["method"] != "loggingMiddleware" {
t.Errorf("msg = %v, want \"http request\"", entry["msg"]) t.Errorf("schema fields = %v, want type=http class=api method=loggingMiddleware", entry)
} }
if entry["method"] != "GET" || entry["path"] != "/api/health" { if _, hasMsg := entry["msg"]; hasMsg {
t.Errorf("method/path = %v/%v, want GET//api/health", entry["method"], entry["path"]) t.Errorf("expected no msg on the http record, got %v", entry["msg"])
}
if entry["http_method"] != "GET" || entry["path"] != "/api/health" {
t.Errorf("http_method/path = %v/%v, want GET//api/health", entry["http_method"], entry["path"])
} }
if entry["status"] != float64(http.StatusOK) { if entry["status"] != float64(http.StatusOK) {
t.Errorf("status = %v, want 200", entry["status"]) t.Errorf("status = %v, want 200", entry["status"])
@@ -1176,10 +1180,11 @@ func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) {
db.Close() // force a downstream DB call to fail with a 500 db.Close() // force a downstream DB call to fail with a 500
var buf bytes.Buffer var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil)) prev := slog.Default()
slog.SetDefault(applog.NewLogger("info", &buf))
defer slog.SetDefault(prev)
req := httptest.NewRequest(http.MethodGet, "/api/profile", nil) req := httptest.NewRequest(http.MethodGet, "/api/profile", nil)
req = req.WithContext(applog.WithLogger(req.Context(), logger))
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure) cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
if err != nil { if err != nil {
t.Fatalf("mint session cookie: %v", err) t.Fatalf("mint session cookie: %v", err)

View File

@@ -45,7 +45,7 @@ func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.
if res.Status == garmin.AuthSuccess { if res.Status == garmin.AuthSuccess {
if err := s.DB.MarkGarminConnected(ctx, userID); err != nil { if err := s.DB.MarkGarminConnected(ctx, userID); err != nil {
applog.FromContext(ctx).Error("mark garmin connected", "user_id", userID, "error", err) applog.App("api.Server", "recordAuthResult").Error("mark garmin connected", "user_id", userID, "error", err)
} }
} }
} }

View File

@@ -15,7 +15,6 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"sync" "sync"
"sync/atomic"
"time" "time"
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
@@ -162,19 +161,12 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
} }
var requestIDCounter atomic.Int64 // loggingMiddleware logs one type=http JSON line per HTTP request. The
// record's meaning is fully carried by its fields (http_method, path,
// loggingMiddleware logs one JSON line per HTTP request (method, // status, duration_ms), so it has no msg; "method" stays reserved for the
// path, status, duration) and attaches a per-request logger (tagged with a // emitting function per the log schema, hence http_method for the verb.
// request_id) to the request context, so any downstream call this request
// triggers -- e.g. a Garmin wrapper round-trip -- logs with the same
// correlating id (see internal/applog, internal/garmin's roundTrip).
func loggingMiddleware(next http.Handler) http.Handler { func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := fmt.Sprintf("req-%d", requestIDCounter.Add(1))
logger := applog.FromContext(r.Context()).With("request_id", id)
r = r.WithContext(applog.WithLogger(r.Context(), logger))
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now() start := time.Now()
next.ServeHTTP(ww, r) next.ServeHTTP(ww, r)
@@ -183,8 +175,11 @@ func loggingMiddleware(next http.Handler) http.Handler {
if ww.Status() >= 500 { if ww.Status() >= 500 {
level = slog.LevelWarn level = slog.LevelWarn
} }
logger.LogAttrs(r.Context(), level, "HTTP request", slog.Default().LogAttrs(r.Context(), level, "",
slog.String("method", r.Method), slog.String("type", "http"),
slog.String("class", "api"),
slog.String("method", "loggingMiddleware"),
slog.String("http_method", r.Method),
slog.String("path", r.URL.Path), slog.String("path", r.URL.Path),
slog.Int("status", ww.Status()), slog.Int("status", ww.Status()),
slog.Int64("duration_ms", time.Since(start).Milliseconds()), slog.Int64("duration_ms", time.Since(start).Milliseconds()),
@@ -261,7 +256,7 @@ func (s *Server) removeUserClient(userID int64) {
if ok { if ok {
if err := client.Close(); err != nil { if err := client.Close(); err != nil {
slog.Error("close garmin client for deleted user", "user_id", userID, "error", err) applog.App("api.Server", "removeUserClient").Error("close garmin client for deleted user", "user_id", userID, "error", err)
} }
} }
if s.ClientConfig.TokenStorePath == "" { if s.ClientConfig.TokenStorePath == "" {
@@ -269,7 +264,7 @@ func (s *Server) removeUserClient(userID int64) {
} }
tokenStoreDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10)) tokenStoreDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
if err := os.RemoveAll(tokenStoreDir); err != nil { if err := os.RemoveAll(tokenStoreDir); err != nil {
slog.Error("remove token store dir for deleted user", "user_id", userID, "error", err) applog.App("api.Server", "removeUserClient").Error("remove token store dir for deleted user", "user_id", userID, "error", err)
} }
} }
@@ -316,7 +311,7 @@ func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error
s.mu.Unlock() s.mu.Unlock()
}() }()
if err := fn(context.Background()); err != nil { if err := fn(context.Background()); err != nil {
slog.Error("background sync failed", "user_id", userID, "error", err) applog.App("api.Server", "backgroundSync").Error("background sync failed", "user_id", userID, "error", err)
} }
}() }()
return true return true
@@ -336,7 +331,7 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil { if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("encode response", "error", err) applog.App("api", "writeJSON").Error("encode response", "error", err)
} }
} }

View File

@@ -64,7 +64,7 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
txnCookie, err := r.Cookie(auth.TxnCookieName) txnCookie, err := r.Cookie(auth.TxnCookieName)
if err != nil { if err != nil {
applog.FromContext(r.Context()).Warn("session callback: missing txn cookie", "error", err) applog.App("api.Server", "handleSessionCallback").Warn("missing txn cookie", "error", err)
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound) http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return return
} }
@@ -72,14 +72,14 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
txn, err := auth.ParseTxnCookie(txnCookie, s.SessionConfig.Secret) txn, err := auth.ParseTxnCookie(txnCookie, s.SessionConfig.Secret)
if err != nil { if err != nil {
applog.FromContext(r.Context()).Warn("session callback: failed to parse txn cookie", "error", err) applog.App("api.Server", "handleSessionCallback").Warn("failed to parse txn cookie", "error", err)
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound) http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return return
} }
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query()) result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
if err != nil { if err != nil {
applog.FromContext(r.Context()).Error("session callback failed (state mismatch, code exchange, or ID-token verification)", "error", err) applog.App("api.Server", "handleSessionCallback").Error("callback failed (state mismatch, code exchange, or ID-token verification)", "error", err)
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound) http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return return
} }

View File

@@ -189,8 +189,16 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
if s.ClientConfig.TokenStorePath != "" { if s.ClientConfig.TokenStorePath != "" {
oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub) oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub)
newDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10)) newDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
// A stale {userID} directory can survive from a previous account
// with the same id -- pre-production the DB file is freely deleted
// and recreated (ids restart at 1) while the token-store root lives
// on, and os.Rename refuses to replace a non-empty directory. The
// just-validated setup session must win, so clear the target first.
if err := os.RemoveAll(newDir); err != nil {
applog.App("api.Server", "handleSetupComplete").Error("remove stale token store dir", "user_id", userID, "error", err)
}
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) { if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
applog.FromContext(r.Context()).Error("rename setup token store dir", "user_id", userID, "error", err) applog.App("api.Server", "handleSetupComplete").Error("rename setup token store dir", "user_id", userID, "error", err)
} }
} }

View File

@@ -254,3 +254,64 @@ func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err) t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err)
} }
} }
// Regression: os.Rename refuses to replace an existing directory, so a
// stale {userID} token-store dir (left behind when the DB file was
// recreated -- ids restart at 1 -- while the token-store root survived)
// used to make setup completion silently keep the OLD tokens in place.
// The fresh, just-validated session's directory must win.
func TestSetupComplete_ReplacesStaleTokenStoreDir(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "setup_stale_dir_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &garmin.MockClient{}
tokenStoreRoot := t.TempDir()
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
router := s.Router()
// The ephemeral setup dir the wrapper would have written tokens into.
setupDir := setupTokenStoreDir(tokenStoreRoot, "test-user")
if err := os.MkdirAll(setupDir, 0o755); err != nil {
t.Fatalf("mkdir setup dir: %v", err)
}
if err := os.WriteFile(filepath.Join(setupDir, "oauth_token"), []byte("fresh"), 0o600); err != nil {
t.Fatalf("write fresh token: %v", err)
}
// A stale dir already occupying the permanent {userID} path (fresh DB
// starts ids at 1).
staleDir := filepath.Join(tokenStoreRoot, "1")
if err := os.MkdirAll(staleDir, 0o755); err != nil {
t.Fatalf("mkdir stale dir: %v", err)
}
if err := os.WriteFile(filepath.Join(staleDir, "oauth_token"), []byte("stale"), 0o600); err != nil {
t.Fatalf("write stale token: %v", err)
}
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
if rec.Code != http.StatusOK {
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
}
token, err := os.ReadFile(filepath.Join(tokenStoreRoot, itoa(u.ID), "oauth_token"))
if err != nil {
t.Fatalf("read token after complete: %v", err)
}
if string(token) != "fresh" {
t.Fatalf("token content = %q, want the fresh setup session to replace the stale dir", token)
}
if _, err := os.Stat(setupDir); !os.IsNotExist(err) {
t.Errorf("ephemeral setup dir still present after rename: %v", err)
}
}

View File

@@ -17,8 +17,6 @@ import (
"strconv" "strconv"
"sync" "sync"
"time" "time"
"geniusrun/backend/internal/log"
) )
//go:embed wrapper/wrapper.py //go:embed wrapper/wrapper.py
@@ -149,14 +147,14 @@ func (c *subprocessClient) ensureStarted(ctx context.Context) error {
if c.scriptPath == "" { if c.scriptPath == "" {
f, err := os.CreateTemp("", "geniusrun-garmin-wrapper-*.py") f, err := os.CreateTemp("", "geniusrun-garmin-wrapper-*.py")
if err != nil { if err != nil {
return fmt.Errorf("write embedded wrapper script: %w", err) return fmt.Errorf("write wrapper script: %w", err)
} }
if _, err := f.WriteString(wrapperScript); err != nil { if _, err := f.WriteString(wrapperScript); err != nil {
f.Close() f.Close()
return fmt.Errorf("write embedded wrapper script: %w", err) return fmt.Errorf("write wrapper script: %w", err)
} }
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
return fmt.Errorf("write embedded wrapper script: %w", err) return fmt.Errorf("write wrapper script: %w", err)
} }
c.scriptPath = f.Name() c.scriptPath = f.Name()
} }
@@ -189,7 +187,7 @@ func (c *subprocessClient) ensureStarted(ctx context.Context) error {
} }
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("spawn garmin wrapper subprocess: %w", err) return fmt.Errorf("spawn wrapper subprocess: %w", err)
} }
// wrapper.py logs auth/rate-limit diagnostics to stderr as JSON lines // wrapper.py logs auth/rate-limit diagnostics to stderr as JSON lines
// (see its _log helper); forwardWrapperStderr re-emits them through the // (see its _log helper); forwardWrapperStderr re-emits them through the
@@ -216,19 +214,22 @@ func (c *subprocessClient) ensureStarted(ctx context.Context) error {
return nil return nil
} }
// roundTrip sends one request and returns its result payload, or an error // execute sends one request and returns its result payload, or an error
// if the wrapper reported one. Callers must hold c.mu and have already // if the wrapper reported one. Callers must hold c.mu and have already
// called ensureStarted. Logs exactly one "Garmin wrapper call" line // called ensureStarted. Logs exactly one type=wrapper line regardless of
// regardless of outcome (see internal/applog) -- cmd/params are always // outcome (the record's meaning is carried by its fields, no msg) --
// safe to log in full here: Garmin credentials only ever reach the // cmd/params are always safe to log in full here: Garmin credentials only
// subprocess via env vars at spawn time (see ensureStarted), never through // ever reach the subprocess via env vars at spawn time (see
// these wire params. // ensureStarted), never through these wire params.
func (c *subprocessClient) roundTrip(ctx context.Context, cmdName string, params any) (result json.RawMessage, err error) { func (c *subprocessClient) execute(ctx context.Context, cmd string, params any) (result json.RawMessage, err error) {
start := time.Now() start := time.Now()
var errorType, traceback string // Python-side failure detail, set from the error response var errorType, traceback string // Python-side failure detail, set from the error response
defer func() { defer func() {
attrs := []slog.Attr{ attrs := []slog.Attr{
slog.String("cmd", cmdName), slog.String("type", "wrapper"),
slog.String("class", "garmin.subprocessClient"),
slog.String("method", "execute"),
slog.String("cmd", cmd),
slog.Any("params", params), slog.Any("params", params),
slog.Int64("duration_ms", time.Since(start).Milliseconds()), slog.Int64("duration_ms", time.Since(start).Milliseconds()),
} }
@@ -246,41 +247,41 @@ func (c *subprocessClient) roundTrip(ctx context.Context, cmdName string, params
attrs = append(attrs, slog.String("traceback", traceback)) attrs = append(attrs, slog.String("traceback", traceback))
} }
} }
applog.FromContext(ctx).LogAttrs(context.Background(), level, "Garmin wrapper call", attrs...) slog.Default().LogAttrs(ctx, level, "", attrs...)
}() }()
c.nextID++ c.nextID++
id := c.nextID id := c.nextID
if err = c.enc.Encode(wireRequest{ID: id, Cmd: cmdName, Params: params}); err != nil { if err = c.enc.Encode(wireRequest{ID: id, Cmd: cmd, Params: params}); err != nil {
err = fmt.Errorf("write %s request: %w", cmdName, err) err = fmt.Errorf("write %s request: %w", cmd, err)
return nil, err return nil, err
} }
if !c.scanner.Scan() { if !c.scanner.Scan() {
if serr := c.scanner.Err(); serr != nil { if serr := c.scanner.Err(); serr != nil {
err = fmt.Errorf("read %s response: %w", cmdName, serr) err = fmt.Errorf("read %s response: %w", cmd, serr)
} else { } else {
err = fmt.Errorf("read %s response: subprocess closed its output", cmdName) err = fmt.Errorf("read %s response: subprocess closed its output", cmd)
} }
return nil, err return nil, err
} }
var resp wireResponse var resp wireResponse
if uerr := json.Unmarshal(c.scanner.Bytes(), &resp); uerr != nil { if uerr := json.Unmarshal(c.scanner.Bytes(), &resp); uerr != nil {
err = fmt.Errorf("parse %s response: %w", cmdName, uerr) err = fmt.Errorf("parse %s response: %w", cmd, uerr)
return nil, err return nil, err
} }
if resp.ID != id { if resp.ID != id {
err = fmt.Errorf("%s response id mismatch: got %d, want %d", cmdName, resp.ID, id) err = fmt.Errorf("%s response id mismatch: got %d, want %d", cmd, resp.ID, id)
return nil, err return nil, err
} }
if resp.Error != "" { if resp.Error != "" {
errorType, traceback = resp.ErrorType, resp.Traceback errorType, traceback = resp.ErrorType, resp.Traceback
if resp.NotFound { if resp.NotFound {
err = fmt.Errorf("%s: %s: %w", cmdName, resp.Error, ErrNotFound) err = fmt.Errorf("%s: %s: %w", cmd, resp.Error, ErrNotFound)
} else { } else {
err = fmt.Errorf("%s: %s", cmdName, resp.Error) err = fmt.Errorf("%s: %s", cmd, resp.Error)
} }
return nil, err return nil, err
} }
@@ -295,7 +296,7 @@ func (c *subprocessClient) Authenticate(ctx context.Context) (AuthResult, error)
if err := c.ensureStarted(ctx); err != nil { if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err return AuthResult{}, err
} }
raw, err := c.roundTrip(ctx, "authenticate", nil) raw, err := c.execute(ctx, "authenticate", nil)
if err != nil { if err != nil {
return AuthResult{}, err return AuthResult{}, err
} }
@@ -313,7 +314,7 @@ func (c *subprocessClient) CompleteMFA(ctx context.Context, code string) (AuthRe
if err := c.ensureStarted(ctx); err != nil { if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err return AuthResult{}, err
} }
raw, err := c.roundTrip(ctx, "complete_mfa", map[string]any{"code": code}) raw, err := c.execute(ctx, "complete_mfa", map[string]any{"code": code})
if err != nil { if err != nil {
return AuthResult{}, err return AuthResult{}, err
} }
@@ -374,7 +375,7 @@ func (c *subprocessClient) close() error {
// the corresponding level with their attrs preserved. Anything that isn't // the corresponding level with their attrs preserved. Anything that isn't
// such a line (a Python startup crash before _log exists, a chatty // such a line (a Python startup crash before _log exists, a chatty
// third-party library printing directly) is wrapped as a warn-level // third-party library printing directly) is wrapped as a warn-level
// "garmin wrapper stderr" record rather than passed through raw, so the // type=wrapper record carrying the raw "line" rather than passed through, so the
// backend's combined output is JSON no matter what the subprocess does. // backend's combined output is JSON no matter what the subprocess does.
func forwardWrapperStderr(r io.Reader) { func forwardWrapperStderr(r io.Reader) {
scanner := bufio.NewScanner(r) scanner := bufio.NewScanner(r)
@@ -383,19 +384,21 @@ func forwardWrapperStderr(r io.Reader) {
line := scanner.Text() line := scanner.Text()
var entry map[string]any var entry map[string]any
if err := json.Unmarshal([]byte(line), &entry); err != nil || entry["msg"] == nil { if err := json.Unmarshal([]byte(line), &entry); err != nil || entry["msg"] == nil {
slog.Warn("garmin wrapper stderr", "line", line) slog.Warn("", "type", "wrapper", "class", "garmin", "method", "forwardWrapperStderr", "line", line)
continue continue
} }
msg, _ := entry["msg"].(string) msg, _ := entry["msg"].(string)
levelName, _ := entry["level"].(string) levelName, _ := entry["level"].(string)
delete(entry, "msg") delete(entry, "msg")
delete(entry, "level") delete(entry, "level")
attrs := make([]slog.Attr, 0, len(entry)+1) // class is the Python module; method arrives in the entry itself
attrs = append(attrs, slog.String("source", "garmin-wrapper")) // (wrapper.py's _log stamps the emitting function).
attrs := make([]slog.Attr, 0, len(entry)+2)
attrs = append(attrs, slog.String("type", "wrapper"), slog.String("class", "wrapper"))
for k, v := range entry { for k, v := range entry {
attrs = append(attrs, slog.Any(k, v)) attrs = append(attrs, slog.Any(k, v))
} }
slog.LogAttrs(context.Background(), wrapperLogLevel(levelName), msg, attrs...) slog.Default().LogAttrs(context.Background(), wrapperLogLevel(levelName), msg, attrs...)
} }
} }
@@ -436,7 +439,7 @@ func (c *subprocessClient) GetActivities(ctx context.Context, startDate, endDate
if err := c.ensureStarted(ctx); err != nil { if err := c.ensureStarted(ctx); err != nil {
return nil, err return nil, err
} }
raw, err := c.roundTrip(ctx, "call", callParams{ raw, err := c.execute(ctx, "call", callParams{
Method: "get_activities_by_date", Method: "get_activities_by_date",
Args: map[string]any{"startdate": startDate, "enddate": endDate}, Args: map[string]any{"startdate": startDate, "enddate": endDate},
}) })
@@ -471,7 +474,7 @@ func (c *subprocessClient) GetActivitySplits(ctx context.Context, activityID int
if err := c.ensureStarted(ctx); err != nil { if err := c.ensureStarted(ctx); err != nil {
return ActivitySplits{}, err return ActivitySplits{}, err
} }
raw, err := c.roundTrip(ctx, "call", callParams{ raw, err := c.execute(ctx, "call", callParams{
Method: "get_activity_splits", Method: "get_activity_splits",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)}, Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
}) })
@@ -506,7 +509,7 @@ func (c *subprocessClient) GetActivityDetails(ctx context.Context, activityID in
if err := c.ensureStarted(ctx); err != nil { if err := c.ensureStarted(ctx); err != nil {
return ActivityDetails{}, err return ActivityDetails{}, err
} }
raw, err := c.roundTrip(ctx, "call", callParams{ raw, err := c.execute(ctx, "call", callParams{
Method: "get_activity_details", Method: "get_activity_details",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)}, Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
}) })
@@ -529,7 +532,7 @@ func (c *subprocessClient) GetWorkoutByID(ctx context.Context, workoutID int64)
if err := c.ensureStarted(ctx); err != nil { if err := c.ensureStarted(ctx); err != nil {
return Workout{}, err return Workout{}, err
} }
raw, err := c.roundTrip(ctx, "call", callParams{ raw, err := c.execute(ctx, "call", callParams{
Method: "get_workout_by_id", Method: "get_workout_by_id",
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)}, Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
}) })

View File

@@ -101,7 +101,7 @@ func TestSubprocessClient_RoundTrip_DetectsIDMismatch(t *testing.T) {
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes) scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner} c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
_, err := c.roundTrip(context.Background(), "authenticate", nil) _, err := c.execute(context.Background(), "authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "id mismatch") { if err == nil || !strings.Contains(err.Error(), "id mismatch") {
t.Fatalf("roundTrip error = %v, want an id mismatch error", err) t.Fatalf("roundTrip error = %v, want an id mismatch error", err)
} }
@@ -123,7 +123,7 @@ func TestSubprocessClient_RoundTrip_SubprocessClosedIsError(t *testing.T) {
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes) scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner} c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
_, err := c.roundTrip(context.Background(), "authenticate", nil) _, err := c.execute(context.Background(), "authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "closed") { if err == nil || !strings.Contains(err.Error(), "closed") {
t.Fatalf("roundTrip error = %v, want a subprocess-closed error", err) t.Fatalf("roundTrip error = %v, want a subprocess-closed error", err)
} }
@@ -134,7 +134,7 @@ func TestSubprocessClient_RoundTrip_WrapperErrorPropagates(t *testing.T) {
return fakeError("boom") return fakeError("boom")
}) })
_, err := c.roundTrip(context.Background(), "authenticate", nil) _, err := c.execute(context.Background(), "authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "boom") { if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("roundTrip error = %v, want it to mention %q", err, "boom") t.Fatalf("roundTrip error = %v, want it to mention %q", err, "boom")
} }
@@ -145,7 +145,7 @@ func TestSubprocessClient_RoundTrip_NotFoundWrapsErrNotFound(t *testing.T) {
return fakeNotFoundError("API Error 404") return fakeNotFoundError("API Error 404")
}) })
_, err := c.roundTrip(context.Background(), "call", nil) _, err := c.execute(context.Background(), "call", nil)
if !errors.Is(err, ErrNotFound) { if !errors.Is(err, ErrNotFound) {
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound)", err) t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound)", err)
} }
@@ -159,7 +159,7 @@ func TestSubprocessClient_RoundTrip_OrdinaryErrorDoesNotWrapErrNotFound(t *testi
return fakeError("boom") return fakeError("boom")
}) })
_, err := c.roundTrip(context.Background(), "call", nil) _, err := c.execute(context.Background(), "call", nil)
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound) to be false", err) t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound) to be false", err)
} }
@@ -436,10 +436,11 @@ func TestSubprocessClient_RoundTrip_LogsCallWithResultPreview(t *testing.T) {
}) })
var buf bytes.Buffer var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil)) prev := slog.Default()
ctx := applog.WithLogger(context.Background(), logger) slog.SetDefault(applog.NewLogger("info", &buf))
defer slog.SetDefault(prev)
if _, err := c.Authenticate(ctx); err != nil { if _, err := c.Authenticate(context.Background()); err != nil {
t.Fatalf("Authenticate: %v", err) t.Fatalf("Authenticate: %v", err)
} }
@@ -447,8 +448,11 @@ func TestSubprocessClient_RoundTrip_LogsCallWithResultPreview(t *testing.T) {
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil { if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String()) t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
} }
if entry["msg"] != "Garmin wrapper call" { if entry["type"] != "wrapper" || entry["class"] != "garmin.subprocessClient" || entry["method"] != "execute" {
t.Errorf("msg = %v, want \"Garmin wrapper call\"", entry["msg"]) t.Errorf("schema fields = %v, want type=wrapper class=garmin.subprocessClient method=execute", entry)
}
if _, hasMsg := entry["msg"]; hasMsg {
t.Errorf("expected no msg on the wrapper-call record, got %v", entry["msg"])
} }
if entry["cmd"] != "authenticate" { if entry["cmd"] != "authenticate" {
t.Errorf("cmd = %v, want authenticate", entry["cmd"]) t.Errorf("cmd = %v, want authenticate", entry["cmd"])
@@ -468,10 +472,11 @@ func TestSubprocessClient_RoundTrip_LogsFailedCallAtErrorLevel(t *testing.T) {
}) })
var buf bytes.Buffer var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil)) prev := slog.Default()
ctx := applog.WithLogger(context.Background(), logger) slog.SetDefault(applog.NewLogger("info", &buf))
defer slog.SetDefault(prev)
if _, err := c.Authenticate(ctx); err == nil { if _, err := c.Authenticate(context.Background()); err == nil {
t.Fatal("expected Authenticate to return an error") t.Fatal("expected Authenticate to return an error")
} }
@@ -490,7 +495,7 @@ func TestSubprocessClient_RoundTrip_LogsFailedCallAtErrorLevel(t *testing.T) {
func TestForwardWrapperStderr_ReemitsJSONAndWrapsFreeText(t *testing.T) { func TestForwardWrapperStderr_ReemitsJSONAndWrapsFreeText(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
prev := slog.Default() prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) slog.SetDefault(applog.NewLogger("debug", &buf))
defer slog.SetDefault(prev) defer slog.SetDefault(prev)
stderr := strings.NewReader( stderr := strings.NewReader(
@@ -511,16 +516,19 @@ func TestForwardWrapperStderr_ReemitsJSONAndWrapsFreeText(t *testing.T) {
if first["level"] != "WARN" || first["msg"] != "startup tokenstore login failed" { if first["level"] != "WARN" || first["msg"] != "startup tokenstore login failed" {
t.Errorf("first = %v, want the wrapper's level and msg preserved", first) t.Errorf("first = %v, want the wrapper's level and msg preserved", first)
} }
if first["error"] != "boom" || first["source"] != "garmin-wrapper" { if first["error"] != "boom" || first["type"] != "wrapper" || first["class"] != "wrapper" {
t.Errorf("first = %v, want error attr preserved and source=garmin-wrapper", first) t.Errorf("first = %v, want error attr preserved and type=wrapper class=wrapper", first)
} }
var second map[string]any var second map[string]any
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
t.Fatalf("second line not JSON: %v", err) t.Fatalf("second line not JSON: %v", err)
} }
if second["msg"] != "garmin wrapper stderr" || second["level"] != "WARN" { if _, hasMsg := second["msg"]; hasMsg {
t.Errorf("second = %v, want free text wrapped as a warn record", second) t.Errorf("second = %v, want no msg on a wrapped free-text record", second)
}
if second["type"] != "wrapper" || second["level"] != "WARN" {
t.Errorf("second = %v, want free text wrapped as a warn type=wrapper record", second)
} }
if line, _ := second["line"].(string); !strings.Contains(line, "Traceback") { if line, _ := second["line"].(string); !strings.Contains(line, "Traceback") {
t.Errorf("second line attr = %q, want the raw text preserved", line) t.Errorf("second line attr = %q, want the raw text preserved", line)

View File

@@ -368,12 +368,12 @@ func (s *Sync) fillPendingWorkouts(ctx context.Context, limit int) error {
// after being linked to this activity) will never succeed on // after being linked to this activity) will never succeed on
// retry -- mark it so ActivitiesMissingWorkout stops // retry -- mark it so ActivitiesMissingWorkout stops
// surfacing it, instead of retrying forever. // surfacing it, instead of retrying forever.
applog.FromContext(ctx).Warn("workout not found on Garmin, marking as such (will not retry)", "garmin_activity_id", a.GarminActivityID, "error", err) applog.App("garmin.Sync", "fillPendingWorkouts").Warn("workout not found on Garmin, marking as such (will not retry)", "garmin_activity_id", a.GarminActivityID, "error", err)
if serr := s.db.SetActivityWorkoutNotFound(ctx, s.userID, a.ID); serr != nil { if serr := s.db.SetActivityWorkoutNotFound(ctx, s.userID, a.ID); serr != nil {
return fmt.Errorf("mark activity %d workout not found: %w", a.GarminActivityID, serr) return fmt.Errorf("mark activity %d workout not found: %w", a.GarminActivityID, serr)
} }
} else { } else {
applog.FromContext(ctx).Warn("fill workout failed, will retry next sync", "garmin_activity_id", a.GarminActivityID, "error", err) applog.App("garmin.Sync", "fillPendingWorkouts").Warn("fill workout failed, will retry next sync", "garmin_activity_id", a.GarminActivityID, "error", err)
} }
} }
s.setProgress(PhaseWorkouts, i+1, len(pending)) s.setProgress(PhaseWorkouts, i+1, len(pending))

View File

@@ -1,3 +1,4 @@
import json
import os import os
import time import time
import queue import queue
@@ -351,3 +352,15 @@ def test_startup_login_late_success_never_overrides_explicit_auth_flow():
release.set() release.set()
time.sleep(0.2) # give the thread ample time to (wrongly) flip it time.sleep(0.2) # give the thread ample time to (wrongly) flip it
assert wrapper._auth_state == "mfa_pending" assert wrapper._auth_state == "mfa_pending"
def test_log_stamps_emitting_method(capsys):
"""Every wrapper log line carries the emitting function as "method" --
the Go side forwards it into the unified log schema."""
def some_emitter():
wrapper._log("info", "hello", extra=1)
some_emitter()
entry = json.loads(capsys.readouterr().err.strip())
assert entry == {"level": "info", "msg": "hello", "method": "some_emitter", "extra": 1}

View File

@@ -29,16 +29,18 @@ def _log(level, msg, **attrs):
stderr line by line and re-emits these through the Go application stderr line by line and re-emits these through the Go application
logger, so the backend's combined output stays a single JSON stream -- logger, so the backend's combined output stays a single JSON stream --
never print free text to stderr or stdout from this process (stdout is never print free text to stderr or stdout from this process (stdout is
reserved for the request/response protocol).""" reserved for the request/response protocol). The emitting function is
entry = {"level": level, "msg": msg} stamped as "method" automatically; the Go side adds the rest of the
log schema (type=wrapper, class=wrapper)."""
entry = {"level": level, "msg": msg, "method": sys._getframe(1).f_code.co_name}
entry.update(attrs) entry.update(attrs)
print(json.dumps(entry), file=sys.stderr, flush=True) print(json.dumps(entry), file=sys.stderr, flush=True)
def _prompt_mfa(): def _prompt_mfa():
_log("debug", "prompt_mfa(): invoked") _log("debug", "invoked")
code = _mfa_input_queue.get(timeout=300) code = _mfa_input_queue.get(timeout=300)
_log("debug", "prompt_mfa(): returning MFA code", code_length=len(code)) _log("debug", "returning MFA code", code_length=len(code))
return code return code
@@ -88,12 +90,12 @@ def _startup_login():
_client = Garmin(email, password) _client = Garmin(email, password)
result_queue = queue.Queue() # private to this call, never shared with authenticate/complete_mfa result_queue = queue.Queue() # private to this call, never shared with authenticate/complete_mfa
def _do_login(): def _do_startup_login():
global _auth_state global _auth_state
_log("debug", "startup_login(): background thread starting _client.login()", tokenstore=TOKENSTORE) _log("debug", "background thread starting _client.login()", tokenstore=TOKENSTORE)
try: try:
_client.login(tokenstore=TOKENSTORE) _client.login(tokenstore=TOKENSTORE)
_log("debug", "startup_login(): _client.login() returned successfully") _log("debug", "_client.login() returned successfully")
result_queue.put(("success", None)) result_queue.put(("success", None))
# A success arriving after the 10s timeout below would land in # A success arriving after the 10s timeout below would land in
# an abandoned queue -- flip the state here too, so later calls # an abandoned queue -- flip the state here too, so later calls
@@ -105,30 +107,30 @@ def _startup_login():
except Exception as exc: except Exception as exc:
_log( _log(
"error", "error",
"startup_login(): _client.login() failed", "_client.login() failed",
error=str(exc), error=str(exc),
error_type=type(exc).__name__, error_type=type(exc).__name__,
traceback=traceback.format_exc(), traceback=traceback.format_exc(),
) )
result_queue.put(("error", str(exc))) result_queue.put(("error", str(exc)))
threading.Thread(target=_do_login, daemon=True).start() threading.Thread(target=_do_startup_login, daemon=True).start()
try: try:
status, err = result_queue.get(timeout=10) status, err = result_queue.get(timeout=10)
_log("debug", "startup_login(): got result within 10s timeout", status=status) _log("debug", "got result within 10s timeout", status=status)
if status == "success": if status == "success":
_auth_state = "authenticated" _auth_state = "authenticated"
else: else:
_log("error", "startup_login(): login failed", error=err) _log("error", "login failed", error=err)
_auth_state = "unauthenticated" _auth_state = "unauthenticated"
except queue.Empty: except queue.Empty:
# No assignment here: the state is already "unauthenticated", and # No assignment here: the state is already "unauthenticated", and
# writing it again could clobber a success the background thread # writing it again could clobber a success the background thread
# records right around the timeout boundary (see _do_login). # records right around the timeout boundary (see _do_startup_login).
_log( _log(
"warn", "warn",
"startup_login(): hit the 10s timeout but still running in the " "hit the 10s timeout but still running in the "
"background and will update auth state if it eventually succeeds", "background and will update auth state if it eventually succeeds",
) )
@@ -149,16 +151,16 @@ def _handle_authenticate(_params):
"message": "GARMIN_EMAIL and GARMIN_PASSWORD environment variables are required.", "message": "GARMIN_EMAIL and GARMIN_PASSWORD environment variables are required.",
} }
def _do_login(): def _do_authenticate_login():
_log("debug", "handle_authenticate(): background thread starting _client.login()", tokenstore=TOKENSTORE) _log("debug", "background thread starting _client.login()", tokenstore=TOKENSTORE)
try: try:
_client.login(tokenstore=TOKENSTORE) _client.login(tokenstore=TOKENSTORE)
_log("debug", "handle_authenticate(): _client.login() returned successfully") _log("debug", "_client.login() returned successfully")
_login_result_queue.put(("success", None)) _login_result_queue.put(("success", None))
except Exception as exc: except Exception as exc:
_log( _log(
"error", "error",
"handle_authenticate(): _client.login() failed", "_client.login() failed",
error=str(exc), error=str(exc),
error_type=type(exc).__name__, error_type=type(exc).__name__,
traceback=traceback.format_exc(), traceback=traceback.format_exc(),
@@ -167,21 +169,21 @@ def _handle_authenticate(_params):
_client = Garmin(email, password) _client = Garmin(email, password)
_client.prompt_mfa = _prompt_mfa _client.prompt_mfa = _prompt_mfa
threading.Thread(target=_do_login, daemon=True).start() threading.Thread(target=_do_authenticate_login, daemon=True).start()
try: try:
status, err = _login_result_queue.get(timeout=10) status, err = _login_result_queue.get(timeout=10)
_log("debug", "handle_authenticate(): got result within 10s timeout", status=status) _log("debug", "got result within 10s timeout", status=status)
if status == "success": if status == "success":
_auth_state = "authenticated" _auth_state = "authenticated"
return {"status": "success", "message": "Authenticated successfully."} return {"status": "success", "message": "Authenticated successfully."}
else: else:
_log("error", "handle_authenticate(): login failed", error=err) _log("error", "login failed", error=err)
return {"status": "failed", "message": f"Authentication failed: {err}"} return {"status": "failed", "message": f"Authentication failed: {err}"}
except queue.Empty: except queue.Empty:
_log( _log(
"warn", "warn",
"handle_authenticate(): hit the 10s timeout with no result yet, reporting mfa_required", "hit the 10s timeout with no result yet, reporting mfa_required",
) )
_auth_state = "mfa_pending" _auth_state = "mfa_pending"
return { return {
@@ -197,12 +199,12 @@ def _handle_complete_mfa(params):
return {"status": "failed", "message": "No MFA in progress. Call authenticate first."} return {"status": "failed", "message": "No MFA in progress. Call authenticate first."}
code = params["code"] code = params["code"]
_log("debug", "handle_complete_mfa(): received a code, pushing to mfa queue", code_length=len(code)) _log("debug", "received a code, pushing to mfa queue", code_length=len(code))
_mfa_input_queue.put(code) _mfa_input_queue.put(code)
try: try:
status, err = _login_result_queue.get(timeout=30) status, err = _login_result_queue.get(timeout=30)
_log("debug", "handle_complete_mfa(): got result", status=status, error=err) _log("debug", "got result", status=status, error=err)
if status == "success": if status == "success":
_auth_state = "authenticated" _auth_state = "authenticated"
return {"status": "success", "message": "MFA accepted. Authenticated successfully."} return {"status": "success", "message": "MFA accepted. Authenticated successfully."}
@@ -210,7 +212,7 @@ def _handle_complete_mfa(params):
except queue.Empty: except queue.Empty:
_log( _log(
"error", "error",
"handle_complete_mfa(): hit the 30s timeout with no result yet, reporting unauthenticated", "hit the 30s timeout with no result yet, reporting unauthenticated",
) )
_auth_state = "unauthenticated" _auth_state = "unauthenticated"
return { return {

View File

@@ -1,12 +1,13 @@
// Package applog provides geniusrun's structured JSON logging: a // Package applog provides geniusrun's structured JSON logging: a
// log/slog-based logger writing to stdout, plus context helpers so a // log/slog-based logger writing to stdout, plus the App helper that tags
// logger enriched in one layer (e.g. internal/api's HTTP middleware, // records with the mandatory schema fields every geniusrun log line
// attaching a request_id) is picked up by another (e.g. internal/garmin's // carries -- "type" ("app" | "http" | "wrapper"), "class" (Go type with
// wrapper-call logging) without either package depending on the other. // its package, or the package/module name for free functions), and
// "method" (the Go or Python function emitting the record). "msg" is
// optional: NewLogger's handler drops it when empty.
package applog package applog
import ( import (
"context"
"io" "io"
"log/slog" "log/slog"
"strings" "strings"
@@ -14,9 +15,28 @@ import (
// NewLogger builds a JSON-handler *slog.Logger writing to w at the given // NewLogger builds a JSON-handler *slog.Logger writing to w at the given
// level ("debug"|"info"|"warn"|"error", case-insensitive; anything else // level ("debug"|"info"|"warn"|"error", case-insensitive; anything else
// defaults to info). // defaults to info). Empty msg values are omitted from the output --
// records whose meaning is fully carried by type/class/method and attrs
// (e.g. the HTTP request line) don't need one.
func NewLogger(level string, w io.Writer) *slog.Logger { func NewLogger(level string, w io.Writer) *slog.Logger {
return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: parseLevel(level)})) return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: parseLevel(level),
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.MessageKey && a.Value.String() == "" {
return slog.Attr{}
}
return a
},
}))
}
// App returns the default logger tagged with the mandatory schema fields
// for an ordinary application record: type=app, plus the emitting class
// and method. The http and wrapper emitters (internal/api's logging
// middleware, internal/garmin's execute/forwardWrapperStderr) tag their
// own type instead.
func App(class, method string) *slog.Logger {
return slog.Default().With("type", "app", "class", class, "method", method)
} }
func parseLevel(level string) slog.Level { func parseLevel(level string) slog.Level {
@@ -31,21 +51,3 @@ func parseLevel(level string) slog.Level {
return slog.LevelInfo 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()
}

View File

@@ -2,7 +2,6 @@ package applog
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"strings" "strings"
@@ -38,17 +37,36 @@ func TestNewLogger_WritesValidJSON(t *testing.T) {
} }
} }
func TestWithLogger_FromContext_RoundTrip(t *testing.T) { func TestNewLogger_OmitsEmptyMsg(t *testing.T) {
logger := slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil)) var buf bytes.Buffer
ctx := WithLogger(context.Background(), logger) logger := NewLogger("info", &buf)
logger.Info("", "type", "http", "status", 200)
if got := FromContext(ctx); got != logger { var decoded map[string]any
t.Errorf("FromContext returned a different logger than what was stashed") if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("output not JSON: %v (%q)", err, buf.String())
}
if _, present := decoded["msg"]; present {
t.Errorf("empty msg should be omitted, got %+v", decoded)
}
if decoded["type"] != "http" {
t.Errorf("attrs lost alongside dropped msg: %+v", decoded)
} }
} }
func TestFromContext_DefaultsWhenNoneSet(t *testing.T) { func TestApp_TagsMandatoryFields(t *testing.T) {
if got := FromContext(context.Background()); got == nil { var buf bytes.Buffer
t.Fatal("FromContext on a bare context returned nil, want slog.Default()") prev := slog.Default()
slog.SetDefault(NewLogger("info", &buf))
defer slog.SetDefault(prev)
App("api.Server", "handleThing").Info("did it", "extra", 1)
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("output not JSON: %v", err)
}
if decoded["type"] != "app" || decoded["class"] != "api.Server" || decoded["method"] != "handleThing" {
t.Errorf("mandatory fields = %+v, want type=app class=api.Server method=handleThing", decoded)
} }
} }