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:
@@ -1146,10 +1146,11 @@ func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) {
|
||||
func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
|
||||
s, _, _ := newTestServer(t)
|
||||
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 = req.WithContext(applog.WithLogger(req.Context(), logger))
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
|
||||
@@ -1157,11 +1158,14 @@ func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
|
||||
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
|
||||
}
|
||||
if entry["msg"] != "HTTP request" {
|
||||
t.Errorf("msg = %v, want \"http request\"", entry["msg"])
|
||||
if entry["type"] != "http" || entry["class"] != "api" || entry["method"] != "loggingMiddleware" {
|
||||
t.Errorf("schema fields = %v, want type=http class=api method=loggingMiddleware", entry)
|
||||
}
|
||||
if entry["method"] != "GET" || entry["path"] != "/api/health" {
|
||||
t.Errorf("method/path = %v/%v, want GET//api/health", entry["method"], entry["path"])
|
||||
if _, hasMsg := entry["msg"]; hasMsg {
|
||||
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) {
|
||||
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
|
||||
|
||||
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 = 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)
|
||||
if err != nil {
|
||||
t.Fatalf("mint session cookie: %v", err)
|
||||
|
||||
@@ -45,7 +45,7 @@ func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.
|
||||
|
||||
if res.Status == garmin.AuthSuccess {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"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"})
|
||||
}
|
||||
|
||||
var requestIDCounter atomic.Int64
|
||||
|
||||
// loggingMiddleware logs one JSON line per HTTP request (method,
|
||||
// path, status, duration) and attaches a per-request logger (tagged with a
|
||||
// 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).
|
||||
// loggingMiddleware logs one type=http JSON line per HTTP request. The
|
||||
// record's meaning is fully carried by its fields (http_method, path,
|
||||
// status, duration_ms), so it has no msg; "method" stays reserved for the
|
||||
// emitting function per the log schema, hence http_method for the verb.
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
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)
|
||||
start := time.Now()
|
||||
next.ServeHTTP(ww, r)
|
||||
@@ -183,8 +175,11 @@ func loggingMiddleware(next http.Handler) http.Handler {
|
||||
if ww.Status() >= 500 {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
logger.LogAttrs(r.Context(), level, "HTTP request",
|
||||
slog.String("method", r.Method),
|
||||
slog.Default().LogAttrs(r.Context(), level, "",
|
||||
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.Int("status", ww.Status()),
|
||||
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
@@ -261,7 +256,7 @@ func (s *Server) removeUserClient(userID int64) {
|
||||
|
||||
if ok {
|
||||
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 == "" {
|
||||
@@ -269,7 +264,7 @@ func (s *Server) removeUserClient(userID int64) {
|
||||
}
|
||||
tokenStoreDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
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()
|
||||
}()
|
||||
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
|
||||
@@ -336,7 +331,7 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("encode response", "error", err)
|
||||
applog.App("api", "writeJSON").Error("encode response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
||||
txnCookie, err := r.Cookie(auth.TxnCookieName)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -72,14 +72,14 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
txn, err := auth.ParseTxnCookie(txnCookie, s.SessionConfig.Secret)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,8 +189,16 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
||||
if s.ClientConfig.TokenStorePath != "" {
|
||||
oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub)
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user