From 321125b29b8dcde4046cf019b588f2bfe4e96d2a Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 08:22:57 +0300 Subject: [PATCH] stats: add /api/v1/stats aggregate endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 2 of lethe-web-ui-aggregates: a single-round-trip /api/v1/stats endpoint that bundles per-tool rollup, daily time-series, 84-cell activity heatmap, top-cwd ranking, hour-of-day distribution, and host split. All six aggregations run fresh per request; no server cache. - internal/domain/stats/buckets.go: pure helpers DailyWindow, FillDaily, HeatmapWindow, HourWindow — fully deterministic, tested independently. - internal/domain/stats/repository.go: Filter/Stats types and Repository.Stats — six SQL queries joined via sessions+turns, owner scope via OwnerScope (AllOwners / SpecificOwner / default), sparkline capped at 60 buckets. - internal/domain/stats/handler.go: Handler.List — ?range=7d|30d|90d|all (default 30d); ?range= → 400 INVALID; ?owner= non-admin → 403. - internal/server/server.go: register *stats.Handler alongside project/sessions. - cmd/lethe/main.go: add statsRepo/statsHnd to var block and steward graph. - cmd/lethe/main_e2e_test.go: add stats.Repository/Handler to e2e graph. --- cmd/lethe/main.go | 7 +- cmd/lethe/main_e2e_test.go | 3 + internal/domain/stats/buckets.go | 67 ++++ internal/domain/stats/buckets_test.go | 237 ++++++++++++++ internal/domain/stats/handler.go | 112 +++++++ internal/domain/stats/handler_test.go | 197 ++++++++++++ internal/domain/stats/repository.go | 386 +++++++++++++++++++++++ internal/domain/stats/repository_test.go | 346 ++++++++++++++++++++ internal/server/server.go | 3 + 9 files changed, 1357 insertions(+), 1 deletion(-) create mode 100644 internal/domain/stats/buckets.go create mode 100644 internal/domain/stats/buckets_test.go create mode 100644 internal/domain/stats/handler.go create mode 100644 internal/domain/stats/handler_test.go create mode 100644 internal/domain/stats/repository.go create mode 100644 internal/domain/stats/repository_test.go diff --git a/cmd/lethe/main.go b/cmd/lethe/main.go index 13fed1ab574e9ea8eed666810d4065436f67342c..21ea41b54f08c254cc36dbf3b1f8f0b9aa17f5ed 100644 --- a/cmd/lethe/main.go +++ b/cmd/lethe/main.go @@ -27,6 +27,7 @@ import ( "sourcecraft.dev/bigbes/lethe/internal/domain/ingest" "sourcecraft.dev/bigbes/lethe/internal/domain/project" "sourcecraft.dev/bigbes/lethe/internal/domain/session" + "sourcecraft.dev/bigbes/lethe/internal/domain/stats" "sourcecraft.dev/bigbes/lethe/internal/platform/database" "sourcecraft.dev/bigbes/lethe/internal/platform/health" "sourcecraft.dev/bigbes/lethe/internal/platform/observability" @@ -99,13 +100,15 @@ func run() int { sessionHnd = &session.Handler{} projectRepo = &project.Repository{} projectHnd = &project.Handler{} + statsRepo = &stats.Repository{} + statsHnd = &stats.Handler{} serverSvc = &server.Server{} ) registered := []any{ loggerSvc, metricsSvc, dbSvc, dbCheckSvc, healthSetSvc, authSvc, ingestRepo, ingestSvc, ingestHnd, - sessionRepo, sessionHnd, projectRepo, projectHnd, serverSvc, + sessionRepo, sessionHnd, projectRepo, projectHnd, statsRepo, statsHnd, serverSvc, } mgr.AddComponent(ctx, @@ -123,6 +126,8 @@ func run() int { steward.MustServiceAsset(sessionHnd), steward.MustServiceAsset(projectRepo), steward.MustServiceAsset(projectHnd), + steward.MustServiceAsset(statsRepo), + steward.MustServiceAsset(statsHnd), steward.MustServiceAsset(serverSvc, steward.Root()), ) diff --git a/cmd/lethe/main_e2e_test.go b/cmd/lethe/main_e2e_test.go index 905bd992010098bd4d66253cb49b114d80a86f2c..abe6a22096134cc239186bf237eb3d292e6f88f4 100644 --- a/cmd/lethe/main_e2e_test.go +++ b/cmd/lethe/main_e2e_test.go @@ -23,6 +23,7 @@ import ( "sourcecraft.dev/bigbes/lethe/internal/domain/ingest" "sourcecraft.dev/bigbes/lethe/internal/domain/project" "sourcecraft.dev/bigbes/lethe/internal/domain/session" + "sourcecraft.dev/bigbes/lethe/internal/domain/stats" "sourcecraft.dev/bigbes/lethe/internal/platform/database" "sourcecraft.dev/bigbes/lethe/internal/platform/health" "sourcecraft.dev/bigbes/lethe/internal/platform/observability" @@ -83,6 +84,8 @@ func TestEndToEnd_MultiUserIsolation(t *testing.T) { steward.MustServiceAsset(&session.Handler{}), steward.MustServiceAsset(&project.Repository{}), steward.MustServiceAsset(&project.Handler{}), + steward.MustServiceAsset(&stats.Repository{}), + steward.MustServiceAsset(&stats.Handler{}), steward.MustServiceAsset(srv, steward.Root()), ) diff --git a/internal/domain/stats/buckets.go b/internal/domain/stats/buckets.go new file mode 100644 index 0000000000000000000000000000000000000000..10d2c817eb76307b7e4f8e84c7cfd58812846621 --- /dev/null +++ b/internal/domain/stats/buckets.go @@ -0,0 +1,67 @@ +// Package stats implements the read-only /api/v1/stats aggregation API. It +// bundles six queries into one round-trip and fills deterministic time windows +// (daily, heatmap, hour-of-day) so missing slots appear as zeros on the wire. +package stats + +import "time" + +// DailyWindow returns days+1 unix seconds (at UTC midnight), oldest first, +// ending today. Example: days=7 returns 8 entries — today plus the 7 prior +// days. Each pair is exactly 86400 seconds apart. +// +// The boundary rule: time.Unix(now,0).UTC().Truncate(24h) gives today's +// midnight in UTC. +func DailyWindow(now int64, days int) []int64 { + today := time.Unix(now, 0).UTC().Truncate(24 * time.Hour).Unix() + out := make([]int64, days+1) + for i := 0; i <= days; i++ { + // oldest first: index 0 is `days` days before today + out[i] = today - int64(days-i)*86400 + } + return out +} + +// FillDaily left-joins rows onto the window. Each window slot appears exactly +// once in the output (oldest first). Missing slots get PerTool: map[string]int64{} +// (empty map, not nil — so JSON encodes as {}). Rows whose DateUnix does not +// match any window slot are silently dropped (they are outside the range). +func FillDaily(window []int64, rows []DailyBucket) []DailyBucket { + // Build a lookup map from the rows. + rowByDate := make(map[int64]DailyBucket, len(rows)) + for _, r := range rows { + rowByDate[r.DateUnix] = r + } + + out := make([]DailyBucket, len(window)) + for i, ts := range window { + if row, ok := rowByDate[ts]; ok { + out[i] = row + } else { + out[i] = DailyBucket{DateUnix: ts, PerTool: map[string]int64{}} + } + } + return out +} + +// HeatmapWindow returns 84 unix seconds (at UTC midnight), oldest first, +// ending today — 12 weeks × 7 days. The length is fixed regardless of the +// request's range. +func HeatmapWindow(now int64) []int64 { + const cells = 84 // 12 weeks × 7 days + today := time.Unix(now, 0).UTC().Truncate(24 * time.Hour).Unix() + out := make([]int64, cells) + for i := 0; i < cells; i++ { + out[i] = today - int64(cells-1-i)*86400 + } + return out +} + +// HourWindow returns 24 zero-initialised HourBucket values, one per hour +// (Hour 0..23), in ascending order. +func HourWindow() []HourBucket { + out := make([]HourBucket, 24) + for i := range out { + out[i] = HourBucket{Hour: i, Count: 0} + } + return out +} diff --git a/internal/domain/stats/buckets_test.go b/internal/domain/stats/buckets_test.go new file mode 100644 index 0000000000000000000000000000000000000000..93c551f2b41f953c13fac512119fa962d1c4aa70 --- /dev/null +++ b/internal/domain/stats/buckets_test.go @@ -0,0 +1,237 @@ +package stats_test + +import ( + "testing" + "time" + + "sourcecraft.dev/bigbes/lethe/internal/domain/stats" +) + +// utcMidnight returns the unix seconds at midnight UTC for the given reference +// time. Mirrors the bucketing rule: time.Unix(now, 0).UTC().Truncate(24h). +func utcMidnight(t time.Time) int64 { + return t.UTC().Truncate(24 * time.Hour).Unix() +} + +// ──────────────────────────────────────────────────────────── +// DailyWindow +// ──────────────────────────────────────────────────────────── + +func TestDailyWindow_Length(t *testing.T) { + now := time.Date(2025, 6, 15, 14, 30, 0, 0, time.UTC).Unix() + for _, days := range []int{0, 7, 30, 90} { + got := stats.DailyWindow(now, days) + if len(got) != days+1 { + t.Errorf("DailyWindow(days=%d): len=%d; want %d", days, len(got), days+1) + } + } +} + +func TestDailyWindow_OldestFirst(t *testing.T) { + now := time.Date(2025, 6, 15, 14, 30, 0, 0, time.UTC).Unix() + got := stats.DailyWindow(now, 7) + for i := 1; i < len(got); i++ { + if got[i] <= got[i-1] { + t.Errorf("DailyWindow not strictly ascending at index %d: %d <= %d", i, got[i], got[i-1]) + } + } +} + +func TestDailyWindow_LastEntryIsToday(t *testing.T) { + ref := time.Date(2025, 6, 15, 23, 59, 59, 0, time.UTC) + got := stats.DailyWindow(ref.Unix(), 7) + wantToday := utcMidnight(ref) + last := got[len(got)-1] + if last != wantToday { + t.Errorf("DailyWindow last entry: got %d; want %d (%v vs %v)", + last, wantToday, + time.Unix(last, 0).UTC(), time.Unix(wantToday, 0).UTC()) + } +} + +func TestDailyWindow_FirstEntryIsDaysMidnightsBack(t *testing.T) { + ref := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + days := 7 + got := stats.DailyWindow(ref.Unix(), days) + wantFirst := utcMidnight(ref.AddDate(0, 0, -days)) + if got[0] != wantFirst { + t.Errorf("DailyWindow first entry: got %d; want %d (%v vs %v)", + got[0], wantFirst, + time.Unix(got[0], 0).UTC(), time.Unix(wantFirst, 0).UTC()) + } +} + +func TestDailyWindow_EachEntryAtMidnightUTC(t *testing.T) { + ref := time.Date(2025, 3, 10, 18, 45, 0, 0, time.UTC) + got := stats.DailyWindow(ref.Unix(), 30) + // Each consecutive pair must be exactly 86400 seconds apart (one UTC day). + for i := 1; i < len(got); i++ { + diff := got[i] - got[i-1] + if diff != 86400 { + t.Errorf("DailyWindow entry spacing at index %d: got %d; want 86400", i, diff) + } + } +} + +func TestDailyWindow_DeterministicAtMidnightBoundary(t *testing.T) { + // Exactly at midnight UTC — today's slot should still be that midnight. + ref := time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC) + got := stats.DailyWindow(ref.Unix(), 3) + last := got[len(got)-1] + if last != ref.Unix() { + t.Errorf("midnight boundary: last=%d; want %d", last, ref.Unix()) + } +} + +// ──────────────────────────────────────────────────────────── +// FillDaily +// ──────────────────────────────────────────────────────────── + +func TestFillDaily_MissingDaysGetEmptyMap(t *testing.T) { + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() + window := stats.DailyWindow(now, 3) // 4 entries + got := stats.FillDaily(window, nil) + if len(got) != 4 { + t.Fatalf("FillDaily(nil rows): len=%d; want 4", len(got)) + } + for _, b := range got { + if b.PerTool == nil { + t.Errorf("missing slot has nil PerTool; want empty map (got DateUnix=%d)", b.DateUnix) + } + } +} + +func TestFillDaily_RowsMatchedToCorrectSlot(t *testing.T) { + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() + window := stats.DailyWindow(now, 3) + + // Seed one row at the second slot (yesterday-2). + target := window[1] + rows := []stats.DailyBucket{ + {DateUnix: target, PerTool: map[string]int64{"cc": 5}}, + } + + got := stats.FillDaily(window, rows) + if len(got) != 4 { + t.Fatalf("len=%d; want 4", len(got)) + } + for _, b := range got { + if b.DateUnix == target { + if b.PerTool["cc"] != 5 { + t.Errorf("slot %d: cc=%d; want 5", target, b.PerTool["cc"]) + } + } else { + if len(b.PerTool) != 0 { + t.Errorf("slot %d: PerTool=%v; want empty", b.DateUnix, b.PerTool) + } + } + } +} + +func TestFillDaily_OutsideWindowRowsDropped(t *testing.T) { + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() + window := stats.DailyWindow(now, 2) + + // Row 1000 days ago — outside the 3-slot window. + outside := utcMidnight(time.Unix(now, 0).UTC().AddDate(0, 0, -1000)) + rows := []stats.DailyBucket{ + {DateUnix: outside, PerTool: map[string]int64{"cc": 99}}, + } + + got := stats.FillDaily(window, rows) + if len(got) != 3 { + t.Fatalf("len=%d; want 3", len(got)) + } + for _, b := range got { + if len(b.PerTool) != 0 { + t.Errorf("slot %d should be empty; got %v", b.DateUnix, b.PerTool) + } + } +} + +func TestFillDaily_PreservesWindowOrder(t *testing.T) { + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() + window := stats.DailyWindow(now, 6) + got := stats.FillDaily(window, nil) + for i := 1; i < len(got); i++ { + if got[i].DateUnix <= got[i-1].DateUnix { + t.Errorf("FillDaily order broken at index %d: %d <= %d", i, got[i].DateUnix, got[i-1].DateUnix) + } + } +} + +// ──────────────────────────────────────────────────────────── +// HeatmapWindow +// ──────────────────────────────────────────────────────────── + +func TestHeatmapWindow_Length(t *testing.T) { + now := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC).Unix() + got := stats.HeatmapWindow(now) + if len(got) != 84 { + t.Errorf("HeatmapWindow: len=%d; want 84", len(got)) + } +} + +func TestHeatmapWindow_LastEntryIsToday(t *testing.T) { + ref := time.Date(2025, 6, 15, 22, 0, 0, 0, time.UTC) + got := stats.HeatmapWindow(ref.Unix()) + wantToday := utcMidnight(ref) + last := got[len(got)-1] + if last != wantToday { + t.Errorf("HeatmapWindow last: got %d; want %d", last, wantToday) + } +} + +func TestHeatmapWindow_OldestFirst(t *testing.T) { + now := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC).Unix() + got := stats.HeatmapWindow(now) + for i := 1; i < len(got); i++ { + if got[i] <= got[i-1] { + t.Errorf("HeatmapWindow not ascending at index %d: %d <= %d", i, got[i], got[i-1]) + } + } +} + +func TestHeatmapWindow_FixedAt84Cells(t *testing.T) { + // Different "now" values — always exactly 84 cells. + nows := []time.Time{ + time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC), + time.Date(2024, 2, 29, 12, 0, 0, 0, time.UTC), // leap year + } + for _, n := range nows { + got := stats.HeatmapWindow(n.Unix()) + if len(got) != 84 { + t.Errorf("HeatmapWindow(%v): len=%d; want 84", n, len(got)) + } + } +} + +// ──────────────────────────────────────────────────────────── +// HourWindow +// ──────────────────────────────────────────────────────────── + +func TestHourWindow_Length(t *testing.T) { + got := stats.HourWindow() + if len(got) != 24 { + t.Errorf("HourWindow: len=%d; want 24", len(got)) + } +} + +func TestHourWindow_AllZero(t *testing.T) { + got := stats.HourWindow() + for _, b := range got { + if b.Count != 0 { + t.Errorf("HourWindow[%d].Count = %d; want 0", b.Hour, b.Count) + } + } +} + +func TestHourWindow_HoursAreContiguous(t *testing.T) { + got := stats.HourWindow() + for i, b := range got { + if b.Hour != i { + t.Errorf("HourWindow[%d].Hour = %d; want %d", i, b.Hour, i) + } + } +} diff --git a/internal/domain/stats/handler.go b/internal/domain/stats/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..7303a8076ce89a0f00f9f57b916730a099cb3138 --- /dev/null +++ b/internal/domain/stats/handler.go @@ -0,0 +1,112 @@ +package stats + +import ( + "context" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "go.bigb.es/auxilia/culpa" + "go.bigb.es/auxilia/scribe" + + "sourcecraft.dev/bigbes/lethe/internal/domain/session" + "sourcecraft.dev/bigbes/lethe/internal/pkg/apierror" + "sourcecraft.dev/bigbes/lethe/internal/pkg/httputil" + "sourcecraft.dev/bigbes/lethe/internal/server/auth" +) + +// allOwnersSentinel is the ?owner=* value that an admin uses to read across +// all owners. Identical to the session and project handler sentinels. +const allOwnersSentinel = "*" + +// validRanges is the set of accepted ?range= values. +var validRanges = map[string]int{ + "7d": 7, + "30d": 30, + "90d": 90, +} + +// Handler is the steward-managed HTTP boundary for the stats aggregation API. +type Handler struct { + Repo *Repository `inject:""` +} + +// Init satisfies the steward Initer contract. +func (h *Handler) Init(_ context.Context) error { return nil } + +// Mount registers the single read route under r. Server.Init mounts this +// inside the /api/v1 group, so the effective path is /api/v1/stats. +func (h *Handler) Mount(r chi.Router) { + r.Get("/stats", h.List) +} + +// List handles GET /stats. Reads ?range=7d|30d|90d|all (defaults to 30d), +// resolves the owner scope, and returns a Stats bundle. +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + scope, err := h.resolveScope(r) + if err != nil { + apierror.Render(w, r, err) + return + } + + now := time.Now().Unix() + f := Filter{Owner: scope, Now: now} + + rangeParam := r.URL.Query().Get("range") + switch { + case rangeParam == "" || rangeParam == "30d": + // default: 30 days + since := now - 30*86400 + f.RangeSince = &since + case rangeParam == "all": + f.RangeSince = nil + default: + days, ok := validRanges[rangeParam] + if !ok { + apierror.Render(w, r, culpa.WithCode( + culpa.WithPublic( + culpa.Errorf("unrecognized range %q", rangeParam), + `?range= must be one of: 7d, 30d, 90d, all`, + ), + "INVALID", + )) + return + } + since := now - int64(days)*86400 + f.RangeSince = &since + } + + result, err := h.Repo.Stats(r.Context(), f) + if err != nil { + apierror.Render(w, r, err) + return + } + + if writeErr := httputil.WriteJSON(w, http.StatusOK, result); writeErr != nil { + slog.Default().ErrorContext(r.Context(), "write stats response", scribe.Err(writeErr)) + } +} + +// resolveScope reads the authenticated identity off the context and the +// optional ?owner= query parameter, then returns the appropriate +// session.OwnerScope. Non-admin requests with ?owner= set are 403. +func (h *Handler) resolveScope(r *http.Request) (session.OwnerScope, error) { + id := auth.MustIdentity(r.Context()) + param := r.URL.Query().Get("owner") + if param == "" { + return session.OwnerScope{User: id.User}, nil + } + if !id.IsAdmin { + return session.OwnerScope{}, culpa.WithCode( + culpa.WithPublic(culpa.New("?owner= is admin-only"), "?owner= is admin-only"), + "FORBIDDEN", + ) + } + if param == allOwnersSentinel { + return session.OwnerScope{User: id.User, AllOwners: true}, nil + } + owner := strings.ToLower(param) + return session.OwnerScope{User: id.User, SpecificOwner: &owner}, nil +} diff --git a/internal/domain/stats/handler_test.go b/internal/domain/stats/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..657ffd1e6f205de0c0ffb35d243edec269c254d9 --- /dev/null +++ b/internal/domain/stats/handler_test.go @@ -0,0 +1,197 @@ +package stats_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "sourcecraft.dev/bigbes/lethe/internal/domain/stats" + "sourcecraft.dev/bigbes/lethe/internal/server/auth" +) + +// fakeAuthMiddleware injects a fixed Identity onto the request context. +func fakeAuthMiddleware(id auth.Identity) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := auth.WithIdentity(r.Context(), id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// newStatsHandler wires a Handler against a fresh in-memory database. +func newStatsHandler(t *testing.T) *stats.Handler { + t.Helper() + repo, _ := newStatsRepo(t) + h := &stats.Handler{Repo: repo} + if err := h.Init(t.Context()); err != nil { + t.Fatalf("stats.Handler.Init: %v", err) + } + return h +} + +// mountWithIdentity builds a chi router with the fake auth middleware and the +// stats handler mounted under /api/v1. +func mountWithIdentity(h *stats.Handler, id auth.Identity) http.Handler { + r := chi.NewRouter() + r.Route("/api/v1", func(r chi.Router) { + r.Use(fakeAuthMiddleware(id)) + h.Mount(r) + }) + return r +} + +// problemBody captures the RFC 7807 fields tests assert on. +type problemBody struct { + Status int `json:"status"` + Code string `json:"code"` +} + +func doStats(t *testing.T, router http.Handler, query string) (*httptest.ResponseRecorder, *stats.Stats) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/v1/stats"+query, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code == http.StatusOK { + var body stats.Stats + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal stats body: %v (body=%s)", err, rec.Body.String()) + } + return rec, &body + } + return rec, nil +} + +func TestStatsHandler_Range7d_Returns200(t *testing.T) { + h := newStatsHandler(t) + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + + rec, body := doStats(t, router, "?range=7d") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if body == nil { + t.Fatal("body is nil") + } + // Daily window for 7d = 8 slots. + if len(body.Daily) != 8 { + t.Errorf("Daily len=%d; want 8", len(body.Daily)) + } + if len(body.Heatmap) != 84 { + t.Errorf("Heatmap len=%d; want 84", len(body.Heatmap)) + } + if len(body.HourOfDay) != 24 { + t.Errorf("HourOfDay len=%d; want 24", len(body.HourOfDay)) + } +} + +func TestStatsHandler_Range30d_Returns200(t *testing.T) { + h := newStatsHandler(t) + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + + rec, body := doStats(t, router, "?range=30d") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + // Daily window for 30d = 31 slots. + if len(body.Daily) != 31 { + t.Errorf("Daily len=%d; want 31", len(body.Daily)) + } +} + +func TestStatsHandler_Range90d_Returns200(t *testing.T) { + h := newStatsHandler(t) + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + + rec, body := doStats(t, router, "?range=90d") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + // Daily window for 90d = 91 slots. + if len(body.Daily) != 91 { + t.Errorf("Daily len=%d; want 91", len(body.Daily)) + } +} + +func TestStatsHandler_RangeAll_Returns200(t *testing.T) { + h := newStatsHandler(t) + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + + rec, _ := doStats(t, router, "?range=all") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestStatsHandler_MissingRange_Defaults30d(t *testing.T) { + h := newStatsHandler(t) + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + + rec, body := doStats(t, router, "") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + // Default 30d window = 31 slots. + if len(body.Daily) != 31 { + t.Errorf("default range Daily len=%d; want 31", len(body.Daily)) + } +} + +func TestStatsHandler_BadRange_Returns400(t *testing.T) { + h := newStatsHandler(t) + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + + for _, bad := range []string{"?range=foo", "?range=7", "?range=7days", "?range=0d"} { + rec, _ := doStats(t, router, bad) + if rec.Code != http.StatusBadRequest { + t.Fatalf("range %q: status=%d; want 400; body=%s", bad, rec.Code, rec.Body.String()) + } + var p problemBody + _ = json.Unmarshal(rec.Body.Bytes(), &p) + if p.Code != "INVALID" { + t.Fatalf("range %q: code=%q; want INVALID", bad, p.Code) + } + } +} + +func TestStatsHandler_NonAdminOwnerParam_Returns403(t *testing.T) { + h := newStatsHandler(t) + router := mountWithIdentity(h, auth.Identity{User: "alice", IsAdmin: false}) + + for _, q := range []string{"?owner=alice", "?owner=bob", "?owner=*"} { + req := httptest.NewRequest(http.MethodGet, "/api/v1/stats"+q, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("query %q: status=%d; want 403; body=%s", q, rec.Code, rec.Body.String()) + } + var p problemBody + _ = json.Unmarshal(rec.Body.Bytes(), &p) + if p.Code != "FORBIDDEN" { + t.Fatalf("query %q: code=%q; want FORBIDDEN", q, p.Code) + } + } +} + +func TestStatsHandler_Mount_RegistersRoute(t *testing.T) { + h := newStatsHandler(t) + router := chi.NewRouter() + router.Route("/api/v1", func(r chi.Router) { + r.Use(fakeAuthMiddleware(auth.Identity{User: "alice"})) + h.Mount(r) + }) + + found := false + _ = chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + if method == http.MethodGet && route == "/api/v1/stats" { + found = true + } + return nil + }) + if !found { + t.Error("expected GET /api/v1/stats registered; not found") + } +} diff --git a/internal/domain/stats/repository.go b/internal/domain/stats/repository.go new file mode 100644 index 0000000000000000000000000000000000000000..3637d4af207646dc15021ec44528e9282373930f --- /dev/null +++ b/internal/domain/stats/repository.go @@ -0,0 +1,386 @@ +package stats + +import ( + "context" + "strings" + + "go.bigb.es/auxilia/culpa" + + "sourcecraft.dev/bigbes/lethe/internal/domain/session" + "sourcecraft.dev/bigbes/lethe/internal/platform/database" +) + +// ToolRollup holds aggregate stats for one tool across the requested range. +// DailySparkline has one entry per daily-window bucket (oldest first), +// containing the turn count for that bucket. +type ToolRollup struct { + Tool string `json:"tool"` + Sessions int64 `json:"sessions"` + Turns int64 `json:"turns"` + TokensIn int64 `json:"tokens_in"` + TokensOut int64 `json:"tokens_out"` + DailySparkline []int64 `json:"daily_sparkline"` +} + +// DailyBucket is one day in the daily time-series. PerTool maps tool name to +// turn count for that day. +type DailyBucket struct { + DateUnix int64 `json:"date_unix"` + PerTool map[string]int64 `json:"per_tool"` +} + +// HeatmapCell is one day in the 84-cell activity heatmap. +type HeatmapCell struct { + DateUnix int64 `json:"date_unix"` + Count int64 `json:"count"` +} + +// CwdRow is one entry in the top-cwd ranking. +type CwdRow struct { + Cwd string `json:"cwd"` + Count int64 `json:"count"` +} + +// HourBucket is one hour-of-day bucket (0..23) with its turn count. +type HourBucket struct { + Hour int `json:"hour"` + Count int64 `json:"count"` +} + +// HostRow is one entry in the host breakdown. +type HostRow struct { + Host string `json:"host"` + Count int64 `json:"count"` +} + +// Stats is the single response bundling all six aggregations. +type Stats struct { + PerTool []ToolRollup `json:"per_tool"` + Daily []DailyBucket `json:"daily"` + Heatmap []HeatmapCell `json:"heatmap"` + TopCwd []CwdRow `json:"top_cwd"` + HourOfDay []HourBucket `json:"hour_of_day"` + HostSplit []HostRow `json:"host_split"` +} + +// Filter controls which rows Stats includes. +// - RangeSince=nil means all time (no lower timestamp bound). +// - Now is injected for deterministic window calculations in tests; in +// production the handler injects time.Now().Unix(). +type Filter struct { + Owner session.OwnerScope + RangeSince *int64 // nil = all + Now int64 // injected for determinism +} + +// Repository is the SQL steward for the stats aggregation API. +type Repository struct { + Database *database.Database `inject:""` +} + +// Init satisfies the steward Initer contract. +func (r *Repository) Init(_ context.Context) error { return nil } + +// ownerClause returns the SQL fragment and arg (if any) for the owner scope, +// joining the given table alias. prefix is the "AND " string prepended when +// used in a WHERE continuation; connector is e.g. "WHERE " for the first clause. +// Returns an empty string and nil arg when AllOwners is set. +func ownerClause(alias string, owner session.OwnerScope) (clause string, arg any, hasArg bool) { + switch { + case owner.AllOwners: + return "", nil, false + case owner.SpecificOwner != nil: + return alias + ".owner = ?", *owner.SpecificOwner, true + default: + return alias + ".owner = ?", owner.User, true + } +} + +// appendOwnerClause appends the owner WHERE/AND clause to the builder. +// sep should be " WHERE " for the first clause, " AND " for subsequent ones. +func appendOwnerClause(sb *strings.Builder, args *[]any, sep, alias string, owner session.OwnerScope) { + clause, arg, hasArg := ownerClause(alias, owner) + if clause == "" { + return + } + sb.WriteString(sep) + sb.WriteString(clause) + if hasArg { + *args = append(*args, arg) + } +} + +// sparklineCap is the maximum number of daily buckets in a ToolRollup sparkline. +const sparklineCap = 60 + +// Stats runs six queries against the turns/sessions tables and returns a +// fully-populated Stats struct. All returned slices are non-nil; missing +// time-window slots are filled with zero values. +func (r *Repository) Stats(ctx context.Context, f Filter) (*Stats, error) { + // Determine daily window size from range. + // dailyDays is the raw request days (may exceed sparklineCap). + // For all-time (RangeSince=nil) we use sparklineCap so Daily has a + // bounded, useful window. + var dailyDays int + if f.RangeSince != nil { + dailyDays = int((f.Now - *f.RangeSince) / 86400) + } else { + dailyDays = sparklineCap + } + + // sparklineDays is the sparkline length, capped at sparklineCap. + sparklineDays := dailyDays + if sparklineDays > sparklineCap { + sparklineDays = sparklineCap + } + + // ── 1. PerTool rollup ─────────────────────────────────────────────────── + type toolRow struct { + Tool string `db:"tool"` + Sessions int64 `db:"sessions"` + Turns int64 `db:"turns"` + TokensIn int64 `db:"tokens_in"` + TokensOut int64 `db:"tokens_out"` + } + var toolRows []toolRow + { + var sb strings.Builder + var args []any + sb.WriteString(` +SELECT + t.tool, + COUNT(DISTINCT s.owner || '|' || s.tool || '|' || s.host || '|' || s.session_id) AS sessions, + COUNT(t.turn_id) AS turns, + COALESCE(SUM(t.tokens_in), 0) AS tokens_in, + COALESCE(SUM(t.tokens_out), 0) AS tokens_out +FROM turns t +JOIN sessions s ON s.owner = t.owner AND s.tool = t.tool AND s.host = t.host AND s.session_id = t.session_id +WHERE 1=1`) + appendOwnerClause(&sb, &args, " AND ", "t", f.Owner) + if f.RangeSince != nil { + sb.WriteString(" AND t.timestamp >= ?") + args = append(args, *f.RangeSince) + } + sb.WriteString(" GROUP BY t.tool ORDER BY turns DESC") + toolRows = make([]toolRow, 0) + if err := r.Database.DB.SelectContext(ctx, &toolRows, sb.String(), args...); err != nil { + return nil, culpa.WithCode(culpa.Wrap(err, "stats per_tool"), "DB_QUERY") + } + } + + // ── 2. Daily time-series ──────────────────────────────────────────────── + // The daily window spans the full requested range (not capped at 60). + dailyWindow := DailyWindow(f.Now, dailyDays) + + var dailyDBRows []dailyBucketDBRow + { + var sb strings.Builder + var args []any + // Bucket by UTC midnight: (timestamp / 86400) * 86400 + sb.WriteString(` +SELECT + (t.timestamp / 86400) * 86400 AS date_unix, + t.tool, + COUNT(t.turn_id) AS turns +FROM turns t +JOIN sessions s ON s.owner = t.owner AND s.tool = t.tool AND s.host = t.host AND s.session_id = t.session_id +WHERE 1=1`) + appendOwnerClause(&sb, &args, " AND ", "t", f.Owner) + if f.RangeSince != nil { + sb.WriteString(" AND t.timestamp >= ?") + args = append(args, *f.RangeSince) + } + sb.WriteString(" GROUP BY date_unix, t.tool ORDER BY date_unix ASC") + dailyDBRows = make([]dailyBucketDBRow, 0) + if err := r.Database.DB.SelectContext(ctx, &dailyDBRows, sb.String(), args...); err != nil { + return nil, culpa.WithCode(culpa.Wrap(err, "stats daily"), "DB_QUERY") + } + } + + // Convert DB rows to DailyBucket slice (sparse), then fill the window. + sparseDaily := buildSparseDaily(dailyDBRows) + filledDaily := FillDaily(dailyWindow, sparseDaily) + + // ── 3. Heatmap (always 84 cells regardless of range) ─────────────────── + heatmapWindow := HeatmapWindow(f.Now) + heatmapStart := heatmapWindow[0] + + type heatmapDBRow struct { + DateUnix int64 `db:"date_unix"` + Count int64 `db:"count"` + } + var heatmapDBRows []heatmapDBRow + { + var sb strings.Builder + var args []any + sb.WriteString(` +SELECT + (t.timestamp / 86400) * 86400 AS date_unix, + COUNT(t.turn_id) AS count +FROM turns t +JOIN sessions s ON s.owner = t.owner AND s.tool = t.tool AND s.host = t.host AND s.session_id = t.session_id +WHERE t.timestamp >= ?`) + args = append(args, heatmapStart) + appendOwnerClause(&sb, &args, " AND ", "t", f.Owner) + sb.WriteString(" GROUP BY date_unix ORDER BY date_unix ASC") + heatmapDBRows = make([]heatmapDBRow, 0) + if err := r.Database.DB.SelectContext(ctx, &heatmapDBRows, sb.String(), args...); err != nil { + return nil, culpa.WithCode(culpa.Wrap(err, "stats heatmap"), "DB_QUERY") + } + } + + // Build heatmap: left-join heatmap DB rows onto the window. + heatmapByDate := make(map[int64]int64, len(heatmapDBRows)) + for _, row := range heatmapDBRows { + heatmapByDate[row.DateUnix] = row.Count + } + heatmap := make([]HeatmapCell, len(heatmapWindow)) + for i, ts := range heatmapWindow { + heatmap[i] = HeatmapCell{DateUnix: ts, Count: heatmapByDate[ts]} + } + + // ── 4. TopCwd (capped at 20) ──────────────────────────────────────────── + var topCwd []CwdRow + { + var sb strings.Builder + var args []any + sb.WriteString(` +SELECT + s.working_dir AS cwd, + COUNT(t.turn_id) AS count +FROM turns t +JOIN sessions s ON s.owner = t.owner AND s.tool = t.tool AND s.host = t.host AND s.session_id = t.session_id +WHERE s.working_dir IS NOT NULL`) + appendOwnerClause(&sb, &args, " AND ", "t", f.Owner) + if f.RangeSince != nil { + sb.WriteString(" AND t.timestamp >= ?") + args = append(args, *f.RangeSince) + } + sb.WriteString(" GROUP BY s.working_dir ORDER BY count DESC LIMIT 20") + topCwd = make([]CwdRow, 0) + if err := r.Database.DB.SelectContext(ctx, &topCwd, sb.String(), args...); err != nil { + return nil, culpa.WithCode(culpa.Wrap(err, "stats top_cwd"), "DB_QUERY") + } + } + + // ── 5. HourOfDay ──────────────────────────────────────────────────────── + type hourDBRow struct { + Hour int `db:"hour"` + Count int64 `db:"count"` + } + var hourDBRows []hourDBRow + { + var sb strings.Builder + var args []any + // SQLite: strftime('%H', ts, 'unixepoch') returns the UTC hour as "00"–"23". + // CAST to INTEGER gives 0..23. + sb.WriteString(` +SELECT + CAST(strftime('%H', t.timestamp, 'unixepoch') AS INTEGER) AS hour, + COUNT(t.turn_id) AS count +FROM turns t +JOIN sessions s ON s.owner = t.owner AND s.tool = t.tool AND s.host = t.host AND s.session_id = t.session_id +WHERE 1=1`) + appendOwnerClause(&sb, &args, " AND ", "t", f.Owner) + if f.RangeSince != nil { + sb.WriteString(" AND t.timestamp >= ?") + args = append(args, *f.RangeSince) + } + sb.WriteString(" GROUP BY hour ORDER BY hour ASC") + hourDBRows = make([]hourDBRow, 0) + if err := r.Database.DB.SelectContext(ctx, &hourDBRows, sb.String(), args...); err != nil { + return nil, culpa.WithCode(culpa.Wrap(err, "stats hour_of_day"), "DB_QUERY") + } + } + // Fill 24-slot window. + hourSlots := HourWindow() + for _, row := range hourDBRows { + if row.Hour >= 0 && row.Hour < 24 { + hourSlots[row.Hour].Count = row.Count + } + } + + // ── 6. HostSplit ───────────────────────────────────────────────────────── + var hostSplit []HostRow + { + var sb strings.Builder + var args []any + sb.WriteString(` +SELECT + s.host, + COUNT(t.turn_id) AS count +FROM turns t +JOIN sessions s ON s.owner = t.owner AND s.tool = t.tool AND s.host = t.host AND s.session_id = t.session_id +WHERE 1=1`) + appendOwnerClause(&sb, &args, " AND ", "t", f.Owner) + if f.RangeSince != nil { + sb.WriteString(" AND t.timestamp >= ?") + args = append(args, *f.RangeSince) + } + sb.WriteString(" GROUP BY s.host ORDER BY count DESC") + hostSplit = make([]HostRow, 0) + if err := r.Database.DB.SelectContext(ctx, &hostSplit, sb.String(), args...); err != nil { + return nil, culpa.WithCode(culpa.Wrap(err, "stats host_split"), "DB_QUERY") + } + } + + // ── Assemble PerTool with sparklines ───────────────────────────────────── + // Sparklines are capped at sparklineCap buckets (most recent). + sparklineSlice := filledDaily + if len(filledDaily) > sparklineDays+1 { + sparklineSlice = filledDaily[len(filledDaily)-(sparklineDays+1):] + } + perToolOut := make([]ToolRollup, 0, len(toolRows)) + for _, tr := range toolRows { + sparkline := make([]int64, len(sparklineSlice)) + for i, b := range sparklineSlice { + sparkline[i] = b.PerTool[tr.Tool] + } + perToolOut = append(perToolOut, ToolRollup{ + Tool: tr.Tool, + Sessions: tr.Sessions, + Turns: tr.Turns, + TokensIn: tr.TokensIn, + TokensOut: tr.TokensOut, + DailySparkline: sparkline, + }) + } + + return &Stats{ + PerTool: perToolOut, + Daily: filledDaily, + Heatmap: heatmap, + TopCwd: topCwd, + HourOfDay: hourSlots, + HostSplit: hostSplit, + }, nil +} + +// buildSparseDaily converts flat DB rows (date_unix, tool, turns) into +// DailyBucket values. Rows are grouped by date_unix. +func buildSparseDaily(rows []dailyBucketDBRow) []DailyBucket { + if len(rows) == 0 { + return []DailyBucket{} + } + // Group by date. + byDate := make(map[int64]map[string]int64) + for _, r := range rows { + if byDate[r.DateUnix] == nil { + byDate[r.DateUnix] = make(map[string]int64) + } + byDate[r.DateUnix][r.Tool] += r.Turns + } + out := make([]DailyBucket, 0, len(byDate)) + for dateUnix, perTool := range byDate { + out = append(out, DailyBucket{DateUnix: dateUnix, PerTool: perTool}) + } + return out +} + +// dailyBucketDBRow is the local scan target for the daily query. +type dailyBucketDBRow struct { + DateUnix int64 `db:"date_unix"` + Tool string `db:"tool"` + Turns int64 `db:"turns"` +} + diff --git a/internal/domain/stats/repository_test.go b/internal/domain/stats/repository_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e7371ba014cef23e71e21e7f1a81c6e7aa21905e --- /dev/null +++ b/internal/domain/stats/repository_test.go @@ -0,0 +1,346 @@ +package stats_test + +import ( + "context" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "modernc.org/sqlite" + + "sourcecraft.dev/bigbes/lethe/internal/config" + "sourcecraft.dev/bigbes/lethe/internal/domain/session" + "sourcecraft.dev/bigbes/lethe/internal/domain/stats" + "sourcecraft.dev/bigbes/lethe/internal/platform/database" +) + +// ──────────────────────────────────────────────────────────── +// DB helpers (local to stats_test package) +// ──────────────────────────────────────────────────────────── + +func newTestDatabase(t *testing.T) *database.Database { + t.Helper() + d := &database.Database{ + Cfg: config.DatabaseConfig{ + Path: ":memory:", + BusyTimeout: 5 * time.Second, + }, + } + if err := d.Init(context.Background()); err != nil { + t.Fatalf("database.Init: %v", err) + } + t.Cleanup(func() { _ = d.Destroy(context.Background()) }) + return d +} + +func newStatsRepo(t *testing.T) (*stats.Repository, *sqlx.DB) { + t.Helper() + d := newTestDatabase(t) + repo := &stats.Repository{Database: d} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("stats.Repository.Init: %v", err) + } + return repo, d.DB +} + +// seedSession inserts a minimal session row (NULL working_dir). +func seedSession(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, startedAt, endedAt int64) { + t.Helper() + _, err := db.Exec(` + INSERT INTO sessions (owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata) + VALUES (?, ?, ?, ?, ?, ?, NULL, ?, NULL)`, + owner, tool, host, sid, startedAt, endedAt, "/tmp/x.jsonl", + ) + if err != nil { + t.Fatalf("seedSession %s/%s/%s/%s: %v", owner, tool, host, sid, err) + } +} + +// seedSessionCwd inserts a session row with a non-NULL working_dir. +func seedSessionCwd(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, startedAt, endedAt int64, cwd string) { + t.Helper() + _, err := db.Exec(` + INSERT INTO sessions (owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`, + owner, tool, host, sid, startedAt, endedAt, cwd, "/tmp/x.jsonl", + ) + if err != nil { + t.Fatalf("seedSessionCwd %s/%s/%s/%s cwd=%s: %v", owner, tool, host, sid, cwd, err) + } +} + +// seedTurn inserts a turn with NULL optional columns. +func seedTurn(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts int64) { + t.Helper() + _, err := db.Exec(` + INSERT INTO turns (owner, tool, host, session_id, turn_id, seq, role, timestamp, content, + model, tokens_in, tokens_out, cost_usd, tool_calls, metadata) + VALUES (?, ?, ?, ?, ?, ?, 'user', ?, 'hello', NULL, NULL, NULL, NULL, NULL, NULL)`, + owner, tool, host, sid, tid, seq, ts, + ) + if err != nil { + t.Fatalf("seedTurn %s/%s: %v", sid, tid, err) + } +} + +// seedTurnFull inserts a turn with tokens and model set. +func seedTurnFull(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts, tokensIn, tokensOut int64) { + t.Helper() + _, err := db.Exec(` + INSERT INTO turns (owner, tool, host, session_id, turn_id, seq, role, timestamp, content, + model, tokens_in, tokens_out, cost_usd, tool_calls, metadata) + VALUES (?, ?, ?, ?, ?, ?, 'assistant', ?, 'response', 'gpt-4', ?, ?, NULL, NULL, NULL)`, + owner, tool, host, sid, tid, seq, ts, tokensIn, tokensOut, + ) + if err != nil { + t.Fatalf("seedTurnFull %s/%s: %v", sid, tid, err) + } +} + +// ──────────────────────────────────────────────────────────── +// Tests +// ──────────────────────────────────────────────────────────── + +func TestStats_EmptyDB(t *testing.T) { + repo, _ := newStatsRepo(t) + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() + days := int64(7) + since := now - days*86400 + + result, err := repo.Stats(context.Background(), stats.Filter{ + Owner: session.OwnerScope{User: "alice"}, + RangeSince: &since, + Now: now, + }) + if err != nil { + t.Fatalf("Stats: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + + // All slices must be non-nil (not nil — JSON must encode as []). + if result.PerTool == nil { + t.Error("PerTool is nil; want non-nil empty slice") + } + if result.Daily == nil { + t.Error("Daily is nil; want non-nil empty slice") + } + if result.Heatmap == nil { + t.Error("Heatmap is nil; want non-nil empty slice") + } + if result.TopCwd == nil { + t.Error("TopCwd is nil; want non-nil empty slice") + } + if result.HourOfDay == nil { + t.Error("HourOfDay is nil; want non-nil slice") + } + if result.HostSplit == nil { + t.Error("HostSplit is nil; want non-nil empty slice") + } +} + +func TestStats_OneTool_ThreeTurns_TwoDays(t *testing.T) { + repo, db := newStatsRepo(t) + + // Two sessions on two different days. + // Day 1 (2025-06-13 UTC midnight = 1749772800): session s1 with 1 turn. + // Day 2 (2025-06-14 UTC midnight = 1749859200): session s2 with 2 turns. + day1 := time.Date(2025, 6, 13, 12, 0, 0, 0, time.UTC).Unix() + day2 := time.Date(2025, 6, 14, 12, 0, 0, 0, time.UTC).Unix() + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() + + seedSession(t, db, "alice", "cc", "host1", "s1", day1, day1+60) + seedSession(t, db, "alice", "cc", "host1", "s2", day2, day2+60) + + seedTurnFull(t, db, "alice", "cc", "host1", "s1", "t1", 1, day1+10, 10, 20) + seedTurnFull(t, db, "alice", "cc", "host1", "s2", "t2", 1, day2+10, 30, 40) + seedTurnFull(t, db, "alice", "cc", "host1", "s2", "t3", 2, day2+20, 50, 60) + + since := now - 7*86400 + result, err := repo.Stats(context.Background(), stats.Filter{ + Owner: session.OwnerScope{User: "alice"}, + RangeSince: &since, + Now: now, + }) + if err != nil { + t.Fatalf("Stats: %v", err) + } + + // PerTool: exactly one tool "cc" with 3 turns. + if len(result.PerTool) != 1 { + t.Fatalf("PerTool len=%d; want 1", len(result.PerTool)) + } + tr := result.PerTool[0] + if tr.Tool != "cc" { + t.Errorf("PerTool[0].Tool=%q; want cc", tr.Tool) + } + if tr.Turns != 3 { + t.Errorf("PerTool[0].Turns=%d; want 3", tr.Turns) + } + if tr.TokensIn != 90 { + t.Errorf("PerTool[0].TokensIn=%d; want 90", tr.TokensIn) + } + if tr.TokensOut != 120 { + t.Errorf("PerTool[0].TokensOut=%d; want 120", tr.TokensOut) + } + + // Daily: window is 8 entries (days=7 → days+1). The two days with turns + // must be populated; others have empty PerTool maps. + if len(result.Daily) != 8 { + t.Fatalf("Daily len=%d; want 8", len(result.Daily)) + } + popCount := 0 + for _, b := range result.Daily { + if len(b.PerTool) > 0 { + popCount++ + } + } + if popCount != 2 { + t.Errorf("Daily populated slots=%d; want 2", popCount) + } +} + +func TestStats_HeatmapAlways84Cells(t *testing.T) { + repo, db := newStatsRepo(t) + + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() + + // Seed some data 100 days ago (outside any 7d/30d/90d range, inside 84-week window). + seedSession(t, db, "alice", "cc", "host1", "s1", now-100*86400, now-100*86400+60) + seedTurn(t, db, "alice", "cc", "host1", "s1", "t1", 1, now-100*86400+10) + + since := now - 7*86400 + result, err := repo.Stats(context.Background(), stats.Filter{ + Owner: session.OwnerScope{User: "alice"}, + RangeSince: &since, + Now: now, + }) + if err != nil { + t.Fatalf("Stats: %v", err) + } + if len(result.Heatmap) != 84 { + t.Errorf("Heatmap len=%d; want 84", len(result.Heatmap)) + } +} + +func TestStats_TopCwdCappedAt20(t *testing.T) { + repo, db := newStatsRepo(t) + + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() + since := now - 90*86400 + + // Insert 25 distinct cwds. + for i := 0; i < 25; i++ { + sid := "s" + string(rune('A'+i)) + cwd := "/code/dir" + string(rune('A'+i)) + seedSessionCwd(t, db, "alice", "cc", "host1", sid, now-int64(i)*86400, now-int64(i)*86400+60, cwd) + seedTurn(t, db, "alice", "cc", "host1", sid, "t"+sid, 1, now-int64(i)*86400+10) + } + + result, err := repo.Stats(context.Background(), stats.Filter{ + Owner: session.OwnerScope{User: "alice"}, + RangeSince: &since, + Now: now, + }) + if err != nil { + t.Fatalf("Stats: %v", err) + } + if len(result.TopCwd) > 20 { + t.Errorf("TopCwd len=%d; want <= 20", len(result.TopCwd)) + } +} + +func TestStats_OwnerScope_AllOwners(t *testing.T) { + repo, db := newStatsRepo(t) + + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() + since := now - 30*86400 + + seedSession(t, db, "alice", "cc", "host1", "sA", now-86400, now) + seedSession(t, db, "bob", "gemini", "host2", "sB", now-86400, now) + seedTurnFull(t, db, "alice", "cc", "host1", "sA", "tA", 1, now-3600, 10, 20) + seedTurnFull(t, db, "bob", "gemini", "host2", "sB", "tB", 1, now-3600, 30, 40) + + result, err := repo.Stats(context.Background(), stats.Filter{ + Owner: session.OwnerScope{User: "admin", AllOwners: true}, + RangeSince: &since, + Now: now, + }) + if err != nil { + t.Fatalf("Stats AllOwners: %v", err) + } + if len(result.PerTool) != 2 { + t.Errorf("AllOwners PerTool len=%d; want 2 (cc + gemini)", len(result.PerTool)) + } +} + +func TestStats_OwnerScope_SpecificOwner(t *testing.T) { + repo, db := newStatsRepo(t) + + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() + since := now - 30*86400 + + seedSession(t, db, "alice", "cc", "host1", "sA", now-86400, now) + seedSession(t, db, "bob", "gemini", "host2", "sB", now-86400, now) + seedTurnFull(t, db, "alice", "cc", "host1", "sA", "tA", 1, now-3600, 10, 20) + seedTurnFull(t, db, "bob", "gemini", "host2", "sB", "tB", 1, now-3600, 30, 40) + + bob := "bob" + result, err := repo.Stats(context.Background(), stats.Filter{ + Owner: session.OwnerScope{User: "admin", SpecificOwner: &bob}, + RangeSince: &since, + Now: now, + }) + if err != nil { + t.Fatalf("Stats SpecificOwner: %v", err) + } + if len(result.PerTool) != 1 || result.PerTool[0].Tool != "gemini" { + t.Errorf("SpecificOwner(bob) PerTool=%v; want [gemini]", result.PerTool) + } +} + +func TestStats_OwnerScope_DefaultScopeIsolation(t *testing.T) { + repo, db := newStatsRepo(t) + + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() + since := now - 30*86400 + + seedSession(t, db, "alice", "cc", "host1", "sA", now-86400, now) + seedSession(t, db, "bob", "gemini", "host2", "sB", now-86400, now) + seedTurnFull(t, db, "alice", "cc", "host1", "sA", "tA", 1, now-3600, 10, 20) + seedTurnFull(t, db, "bob", "gemini", "host2", "sB", "tB", 1, now-3600, 30, 40) + + result, err := repo.Stats(context.Background(), stats.Filter{ + Owner: session.OwnerScope{User: "alice"}, + RangeSince: &since, + Now: now, + }) + if err != nil { + t.Fatalf("Stats default scope: %v", err) + } + if len(result.PerTool) != 1 || result.PerTool[0].Tool != "cc" { + t.Errorf("default scope(alice) PerTool=%v; want [cc]", result.PerTool) + } +} + +func TestStats_AllScopeNil_ReturnsAll(t *testing.T) { + repo, db := newStatsRepo(t) + + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() + + seedSession(t, db, "alice", "cc", "host1", "sA", now-86400, now) + seedTurnFull(t, db, "alice", "cc", "host1", "sA", "tA", 1, now-3600, 10, 20) + + // RangeSince = nil means all time. + result, err := repo.Stats(context.Background(), stats.Filter{ + Owner: session.OwnerScope{User: "alice"}, + Now: now, + }) + if err != nil { + t.Fatalf("Stats(nil range): %v", err) + } + if len(result.PerTool) != 1 { + t.Errorf("nil range PerTool len=%d; want 1", len(result.PerTool)) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index ef2d3f10d45855ac609a5c9693eb9286a74996b8..7195d1e6b07b623df95d9000ca26112991a393d9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -32,6 +32,7 @@ import ( "sourcecraft.dev/bigbes/lethe/internal/domain/ingest" "sourcecraft.dev/bigbes/lethe/internal/domain/project" "sourcecraft.dev/bigbes/lethe/internal/domain/session" + "sourcecraft.dev/bigbes/lethe/internal/domain/stats" "sourcecraft.dev/bigbes/lethe/internal/pkg/apierror" "sourcecraft.dev/bigbes/lethe/internal/pkg/httputil" "sourcecraft.dev/bigbes/lethe/internal/platform/health" @@ -58,6 +59,7 @@ type Server struct { Ingest *ingest.Handler `inject:""` Sessions *session.Handler `inject:""` Projects *project.Handler `inject:""` + Stats *stats.Handler `inject:""` router *chi.Mux httpSrv *http.Server @@ -100,6 +102,7 @@ func (s *Server) Init(_ context.Context) error { s.Ingest.Mount(r) s.Sessions.Mount(r) s.Projects.Mount(r) + s.Stats.Mount(r) }) // SPA catch-all: serves the embedded React app for all non-API GET paths.