diff --git a/README.md b/README.md index 6c22283..9005605 100644 --- a/README.md +++ b/README.md @@ -453,6 +453,13 @@ The `sizes` parameter overrides the default ladder (0.1 → 5000 USDC across 12 rungs). The default sizes and the rationale for each rung are documented in **[docs/ladder-sizes.md](docs/ladder-sizes.md)**. +**`/healthz`** answers liveness and data age. `status` is process liveness; +`data` reports each corridor's newest stored record and its age +(`recorded_at`, `age_seconds`, `age_human`) — the thing actually at risk on a +`-history-first` deployment, whose served history is only as fresh as its last +deploy. `data` is `null` when no history exists to describe: unknown, never a +fabricated age. + Beyond the contracts above, one field to know: **`live`** is on every response. `false` means the reading came from history because a live measurement failed, and `stale` then carries its age. With no stored run, the request errors — diff --git a/server/api.go b/server/api.go index 647375d..d9c51c6 100644 --- a/server/api.go +++ b/server/api.go @@ -275,7 +275,58 @@ func (s *Server) handleAssets(w http.ResponseWriter, r *http.Request) { } 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]any{ + "status": "ok", + "data": s.healthData(r.Context()), + }) +} + +// healthData returns the newest stored run per corridor and its age, or nil +// when no history exists to describe. +// +// A health probe answers two different questions: is the process alive, and +// is the data it serves current? /healthz has always answered the first; the +// second is the one at risk on a -history-first deployment, whose served +// history is as old as the image it was built from. nil (JSON null) is the +// explicit unknown — no store, or no stored run — never a fabricated zero or +// "now". +func (s *Server) healthData(ctx context.Context) map[string]healthCorridorJSON { + if s.Store == nil { + return nil + } + corridors, err := s.Store.Corridors(ctx) + if err != nil || len(corridors) == 0 { + return nil + } + now := time.Now().UTC() + out := make(map[string]healthCorridorJSON, len(corridors)) + for _, c := range corridors { + rec, err := s.Store.Latest(ctx, c) + if err != nil || rec == nil { + continue + } + age := now.Sub(rec.RecordedAt.UTC()) + if age < 0 { + age = 0 + } + out[c] = healthCorridorJSON{ + RecordedAt: rec.RecordedAt.UTC().Format(time.RFC3339), + AgeSeconds: int64(age.Seconds()), + AgeHuman: humanAge(age), + } + } + if len(out) == 0 { + return nil + } + return out +} + +// healthCorridorJSON is one corridor's newest stored run, as reported on +// /healthz. +type healthCorridorJSON struct { + RecordedAt string `json:"recorded_at"` + AgeSeconds int64 `json:"age_seconds"` + AgeHuman string `json:"age_human"` } // helpers -------------------------------------------------------------------- diff --git a/server/api_test.go b/server/api_test.go index 43151ff..9ddcfcd 100644 --- a/server/api_test.go +++ b/server/api_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "io" "net/http" @@ -15,6 +16,7 @@ import ( "github.com/Wayfare-labs/wayfare/dex" "github.com/Wayfare-labs/wayfare/refrate" "github.com/Wayfare-labs/wayfare/route" + "github.com/Wayfare-labs/wayfare/runstore" ) // liveNGNCPaths is the real Horizon strict-send body for USDC -> NGNC, @@ -286,6 +288,107 @@ func TestHealthz(t *testing.T) { if status != http.StatusOK || body["status"] != "ok" { t.Errorf("healthz = %d %v", status, body) } + // No store configured: data must be null — the age of the data is + // unknown, never a fabricated zero or "now". + if data, ok := body["data"]; !ok || data != nil { + t.Errorf("data = %v, want null when no history is configured", data) + } +} + +// TestHealthzReportsDataAge is the A6 contract: on a -history-first +// deployment the thing at risk is the age of the data, and /healthz must say +// it. Each corridor's newest record is reported with its recorded_at and age; +// a corridor with no history is absent rather than guessed. +func TestHealthzReportsDataAge(t *testing.T) { + base := time.Now().UTC().Add(-6 * time.Hour).Truncate(time.Second) + st, err := runstore.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + appendHealthRecord(t, st, "USDC-NGNC", base) + appendHealthRecord(t, st, "USDC-GHSC", base.Add(-1*time.Hour)) + + srv := trendServer(t, st) + status, body := getJSON(t, srv.URL+"/healthz") + if status != http.StatusOK || body["status"] != "ok" { + t.Fatalf("healthz = %d %v", status, body) + } + + data, _ := body["data"].(map[string]any) + if data == nil { + t.Fatal("data is null; the age of the data must be reported on a history-first deployment") + } + + ngnc, _ := data["USDC-NGNC"].(map[string]any) + if ngnc == nil { + t.Fatalf("data lacks USDC-NGNC: %v", data) + } + if ngnc["recorded_at"] != base.Format(time.RFC3339) { + t.Errorf("recorded_at = %v, want %s", ngnc["recorded_at"], base.Format(time.RFC3339)) + } + age, _ := ngnc["age_seconds"].(float64) + if age < 6*3600 || age > 6*3600+60 { + t.Errorf("age_seconds = %v, want roughly 6h", age) + } + if ngnc["age_human"] != "6h ago" { + t.Errorf("age_human = %v, want 6h ago", ngnc["age_human"]) + } + + ghsc, _ := data["USDC-GHSC"].(map[string]any) + if ghsc == nil { + t.Fatalf("data lacks USDC-GHSC: %v", data) + } + ageGHSC, _ := ghsc["age_seconds"].(float64) + if ageGHSC < 7*3600 || ageGHSC > 7*3600+60 { + t.Errorf("GHSC age_seconds = %v, want roughly 7h", ageGHSC) + } + if ghsc["age_human"] != "7h ago" { + t.Errorf("GHSC age_human = %v, want 7h ago", ghsc["age_human"]) + } +} + +// TestHealthzEmptyStoreIsUnknown is the other half of the unknown case: a +// store exists but holds nothing. The age must be null, never a fabricated +// zero or "now" — an unavailable quantity is unknown, never a default. +func TestHealthzEmptyStoreIsUnknown(t *testing.T) { + empty, err := runstore.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + srv := trendServer(t, empty) + status, body := getJSON(t, srv.URL+"/healthz") + if status != http.StatusOK { + t.Fatalf("healthz = %d %v", status, body) + } + if data, ok := body["data"]; !ok || data != nil { + t.Errorf("data = %v, want null when the store is empty", data) + } +} + +// appendHealthRecord appends one record for the given corridor and time. +func appendHealthRecord(t *testing.T, st runstore.Store, corridor string, at time.Time) { + t.Helper() + rec := &runstore.Record{ + RecordedAt: at, + Corridor: corridor, + Integrity: "DIRECT", + Reference: runstore.Reference{ + Mid: "1350.2568", Source: "currency-api", + AsOf: at.UTC().Format(time.RFC3339), ScoredAgainst: "currency-api", + }, + FloorLossPct: "25.02", FloorSize: "0.1", + WorstLossPct: "97.68", WorstSize: "5000", + Recommended: nil, + Finding: "No usable size.", + Rungs: []runstore.Rung{{ + SendAmount: "0.1", Priced: true, Integrity: "DIRECT", + ReceiveAmount: "102.78", EffectiveRate: "1027.84", + LossPct: "24.65", Verdict: "UNUSABLE", Path: "USDC -> " + corridor, + }}, + } + if err := st.Append(context.Background(), rec); err != nil { + t.Fatal(err) + } } // TestUIScoredTrueRendersVerdicts checks that when scored is true (the normal