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:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user