carlos / platform Public

console: economics assembly with cached period sweep

Splits statementsPass's per-day metrics/edge reading and accumulation
out into sweepUsage(ctx, from, to) — a generic bucket-sweep primitive
now shared with LoadEconomics, which prices sweepUsage's result under
the active pricing scenario (economics.ActiveKey -> named scenario ->
Validate, falling back to DefaultScenario with a warning unless
active.json is simply absent) for three periods (mtd/last/7d), cached
per period with the same fresh/stale-while-revalidate/cold shape as
LoadMetrics.

statementsPass becomes a thin caller: sweep the month, add storage
(ValidAccount-guarded, dedup-logged) and census meters on top, upsert.
All of Task 10's existing statementsPass/storagePass/censusPass tests
pass unmodified, proving the split preserved behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 845d4e808a7bb0c6e3f544ad1783401ab02ea9fc parent 48baae0
4 files changed, +824 −55
  • internal/console/economicspass.go +163 −55
  • internal/console/economicsview.go +485 −0
  • internal/console/economicsview_test.go +171 −0
  • internal/console/server.go +5 −0
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go
index 005863b01..71c0edd91 100644
--- a/internal/console/economicspass.go
+++ b/internal/console/economicspass.go
@@ -13,6 +13,7 @@ import (
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/economics"
"github.com/carlosframework/platform/internal/metrics"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/registry"
@@ -211,73 +212,110 @@ func unitOwner(routes []registry.Route, unit string) (acct, app string, ok bool)
return release.OpsAccount, "", false
}
-// statementsPass folds the month's metrics, edge and storage days (plus
-// census, for app_months) into one usage.Statement per account, create-or-
-// replace via usage.UpsertStatement — safe to run repeatedly as new days'
-// objects appear. Every UTC day of the month up to and including now is a
-// candidate; a day is COVERED (CoveredDays, shared across every account's
-// statement this pass writes, out of DaysInMonth) when at least one source
-// object — a metrics day rollup on any box, an edge day on any box, or the
-// day's storage sweep — exists for that date; a day with none of those
-// simply contributes nothing, uncovered, never a zero standing in for
-// missing data (package usage's "modelling, not invoice" posture).
+// usageSweep is sweepUsage's result: the box- and account-shaped meters
+// accumulated by reading metrics and edge days over [from, to], plus enough
+// coverage bookkeeping for callers to label a total honestly. It deliberately
+// does NOT include storage or census meters — see sweepUsage's doc comment —
+// so a caller that needs those (statementsPass) adds them itself afterward.
+type usageSweep struct {
+ PerAccount map[string]map[string]float64 // account -> meter -> value
+ PerBox map[string]economics.BoxLoad // box -> period load (Hours, per-account CPU/mem)
+ BoxDays map[string]int // box -> days with any metrics rollup
+ // CoveredDays counts UTC days in [from, to] with at least one source
+ // object — a metrics day rollup on any box, an edge day on any box, or
+ // the day's storage sweep (checked here for validity only, never
+ // accumulated — see below) — out of PeriodDays. A day with none of
+ // those contributes nothing, uncovered, never a zero standing in for
+ // missing data (package usage's "modelling, not invoice" posture).
+ CoveredDays, PeriodDays int
+ // EgressMetered marks every box that has EVER written a usage/edge day
+ // (discovered via ListDirs on usage.EdgeBoxesPrefix, independent of
+ // [from, to]) — a box that reports metrics but has no edge component at
+ // all is simply absent from this map, so `sweep.EgressMetered[box]`
+ // defaults to false for it, matching the honesty rule: a caller must
+ // show "unmetered", never a zero that reads as "confirmed no egress".
+ EgressMetered map[string]bool
+}
+
+// sweepUsage reads every UTC day in [from, to] (both truncated to midnight,
+// inclusive) from metrics and edge day objects and accumulates them into
+// account- and box-shaped meters — the per-day reading/accumulation shared
+// by statementsPass (whole month to date, create-or-replace statements) and
+// Task 11's LoadEconomics (arbitrary period, priced live under a scenario).
//
-// Known shortfall, recorded rather than fixed at modelling grade: an account
-// deleted mid-month drops out of s.store.Accounts() (its query filters
-// deleted_at), so this pass stops upserting its statement from the day of
-// deletion onward — the account's last statement object simply stops moving
-// forward, rather than the month closing it out explicitly.
+// Storage and census are deliberately NOT accumulated here: storage's
+// account attribution needs a ValidAccount guard with pass-scoped dedup
+// logging (statementsPass's badStorageKeys) that only makes sense once per
+// whole pass, and census never gates coverage at all — both stay in
+// statementsPass, added on top of this sweep's result. Storage's day
+// objects ARE still consulted here, for CoveredDays only: a day with a
+// valid storage sweep and nothing else is still a covered day, exactly as
+// before this function existed, even though the bytes themselves are read
+// again downstream.
//
-// Second known shortfall: unitOwner attributes a sampled unit against
-// s.cfg.Registry, which is this CONSOLE's own box-local registry, while this
-// pass iterates units published by every box under metrics/boxes/. On a
-// multi-box deployment a unit sampled on a box other than this console's own
-// can fail to match any route in this registry and silently fall to
-// release.OpsAccount, indistinguishable from the intended platform-overhead
-// fallback. Both production deployments are single-box today, so this is
-// currently inert; the structural fix is routes-as-bucket-records (PR #137,
-// unmerged), which would give every pass a registry view that spans boxes
-// instead of one box's local file.
-func (s *Server) statementsPass(ctx context.Context, now time.Time) error {
+// unitOwner attributes a sampled unit against s.cfg.Registry, which is this
+// CONSOLE's own box-local registry, while this sweep iterates units
+// published by every box under metrics/boxes/. On a multi-box deployment a
+// unit sampled on a box other than this console's own can fail to match any
+// route in this registry and silently fall to release.OpsAccount,
+// indistinguishable from the intended platform-overhead fallback. Both
+// production deployments are single-box today, so this is currently inert;
+// the structural fix is routes-as-bucket-records (PR #137, unmerged), which
+// would give every pass a registry view that spans boxes instead of one
+// box's local file.
+func (s *Server) sweepUsage(ctx context.Context, from, to time.Time) (*usageSweep, error) {
reg, err := s.openPassRegistry()
if err != nil {
- return fmt.Errorf("console: statements pass: %w", err)
+ return nil, fmt.Errorf("console: sweep usage: %w", err)
}
routes, err := reg.List()
if err != nil {
- return fmt.Errorf("console: statements pass: %w", err)
+ return nil, fmt.Errorf("console: sweep usage: %w", err)
}
- nowUTC := now.UTC()
- y, mo, today := nowUTC.Date()
- daysInMonth := time.Date(y, mo+1, 0, 0, 0, 0, 0, time.UTC).Day()
- month := usage.MonthOf(nowUTC)
-
metricsBoxes, err := s.cfg.Releases.ListDirs(ctx, metricsBoxesPrefix)
if err != nil {
- return fmt.Errorf("console: statements pass: %w", err)
+ return nil, fmt.Errorf("console: sweep usage: %w", err)
}
edgeBoxes, err := s.cfg.Releases.ListDirs(ctx, usage.EdgeBoxesPrefix)
if err != nil {
- return fmt.Errorf("console: statements pass: %w", err)
+ return nil, fmt.Errorf("console: sweep usage: %w", err)
+ }
+
+ sweep := &usageSweep{
+ PerAccount: map[string]map[string]float64{},
+ PerBox: map[string]economics.BoxLoad{},
+ BoxDays: map[string]int{},
+ EgressMetered: map[string]bool{},
+ }
+ // A box is metered for egress the moment it has EVER written a day —
+ // independent of the swept period, matching the struct's "wrote any
+ // usage/edge day" contract.
+ for _, boxPrefix := range edgeBoxes {
+ box := strings.TrimSuffix(strings.TrimPrefix(boxPrefix, usage.EdgeBoxesPrefix), "/")
+ sweep.EgressMetered[box] = true
}
- meters := map[string]map[string]float64{} // account -> meter -> value
touch := func(acct string) map[string]float64 {
- mm := meters[acct]
+ mm := sweep.PerAccount[acct]
if mm == nil {
mm = map[string]float64{}
- meters[acct] = mm
+ sweep.PerAccount[acct] = mm
}
return mm
}
- // badStorageKeys logs a malformed storage-day key at most once per key
- // for this whole pass, not once per day it recurs within the month.
- badStorageKeys := map[string]bool{}
+ boxLoad := func(box string) economics.BoxLoad {
+ bl, ok := sweep.PerBox[box]
+ if !ok {
+ bl = economics.BoxLoad{Box: box, ByAccount: map[string]economics.BoxAccountLoad{}}
+ }
+ return bl
+ }
- coveredDays := 0
- for day := 1; day <= today; day++ {
- date := time.Date(y, mo, day, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
+ fromDay := from.UTC().Truncate(24 * time.Hour)
+ toDay := to.UTC().Truncate(24 * time.Hour)
+ for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) {
+ date := usage.DayOf(d)
covered := false
for _, boxPrefix := range metricsBoxes {
@@ -288,28 +326,39 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error {
}
var roll metrics.DayRollup
if jerr := json.Unmarshal(raw, &roll); jerr != nil {
- log.Printf("console: statements pass: parse %s: %v", metrics.DayKey(box, date), jerr)
+ log.Printf("console: sweep usage: parse %s: %v", metrics.DayKey(box, date), jerr)
continue
}
covered = true
+ sweep.BoxDays[box]++
+ bl := boxLoad(box)
+ bl.Hours += float64(len(roll.Hours))
for _, hour := range roll.Hours {
for unit, um := range hour.Units {
acct, _, _ := unitOwner(routes, unit)
mm := touch(acct)
var instN int
+ var cpuCoreHours, memGBHours float64
if cpu, ok := um["cpu"]; ok {
- mm[usage.MeterCPUCoreHours] += cpu.Avg * float64(cpu.N) / 60
+ cpuCoreHours = cpu.Avg * float64(cpu.N) / 60
+ mm[usage.MeterCPUCoreHours] += cpuCoreHours
instN = cpu.N
}
if mem, ok := um["mem_bytes"]; ok {
- mm[usage.MeterMemGBHours] += mem.Avg * float64(mem.N) / 60 / 1e9
+ memGBHours = mem.Avg * float64(mem.N) / 60 / 1e9
+ mm[usage.MeterMemGBHours] += memGBHours
if instN == 0 {
instN = mem.N
}
}
mm[usage.MeterInstanceHours] += float64(instN) / 60
+ al := bl.ByAccount[acct]
+ al.CPUCoreHours += cpuCoreHours
+ al.MemGBHours += memGBHours
+ bl.ByAccount[acct] = al
}
}
+ sweep.PerBox[box] = bl
}
for _, boxPrefix := range edgeBoxes {
@@ -320,7 +369,7 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error {
}
var ed usage.EdgeDay
if jerr := json.Unmarshal(raw, &ed); jerr != nil {
- log.Printf("console: statements pass: parse %s: %v", usage.EdgeDayKey(box, date), jerr)
+ log.Printf("console: sweep usage: parse %s: %v", usage.EdgeDayKey(box, date), jerr)
continue
}
covered = true
@@ -337,12 +386,74 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error {
}
}
+ // Storage is consulted here for coverage ONLY — a valid day object
+ // makes the day covered, exactly as before this sweep existed — but
+ // its bytes are not accumulated: that needs statementsPass's
+ // ValidAccount guard and pass-scoped dedup logging. Missing = skip,
+ // uncovered by storage (metrics/edge may still have covered it).
+ if raw, _, gerr := s.cfg.Releases.Get(ctx, usage.StorageDayKey(date)); gerr == nil {
+ var sd usage.StorageDay
+ if jerr := json.Unmarshal(raw, &sd); jerr == nil {
+ covered = true
+ }
+ }
+
+ if covered {
+ sweep.CoveredDays++
+ }
+ sweep.PeriodDays++
+ }
+
+ return sweep, nil
+}
+
+// statementsPass folds the month's metrics, edge and storage days (plus
+// census, for app_months) into one usage.Statement per account, create-or-
+// replace via usage.UpsertStatement — safe to run repeatedly as new days'
+// objects appear. It is a thin caller over sweepUsage (below), which does
+// the actual per-day metrics/edge/storage reading and accumulation shared
+// with Task 11's LoadEconomics: sweep the whole month to date, then fold in
+// the two meter families sweepUsage deliberately leaves out — storage
+// (needs the ValidAccount guard below) and census (doesn't gate coverage at
+// all) — before upserting.
+//
+// Known shortfall, recorded rather than fixed at modelling grade: an account
+// deleted mid-month drops out of s.store.Accounts() (its query filters
+// deleted_at), so this pass stops upserting its statement from the day of
+// deletion onward — the account's last statement object simply stops moving
+// forward, rather than the month closing it out explicitly.
+func (s *Server) statementsPass(ctx context.Context, now time.Time) error {
+ nowUTC := now.UTC()
+ y, mo, today := nowUTC.Date()
+ daysInMonth := time.Date(y, mo+1, 0, 0, 0, 0, 0, time.UTC).Day()
+ month := usage.MonthOf(nowUTC)
+ monthStart := time.Date(y, mo, 1, 0, 0, 0, 0, time.UTC)
+
+ sweep, err := s.sweepUsage(ctx, monthStart, nowUTC)
+ if err != nil {
+ return fmt.Errorf("console: statements pass: %w", err)
+ }
+ meters := sweep.PerAccount // account -> meter -> value; sweepUsage's map, extended below
+ touch := func(acct string) map[string]float64 {
+ mm := meters[acct]
+ if mm == nil {
+ mm = map[string]float64{}
+ meters[acct] = mm
+ }
+ return mm
+ }
+ // badStorageKeys logs a malformed storage-day key at most once per key
+ // for this whole pass, not once per day it recurs within the month.
+ badStorageKeys := map[string]bool{}
+
+ for day := 1; day <= today; day++ {
+ date := time.Date(y, mo, day, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
+
if raw, _, gerr := s.cfg.Releases.Get(ctx, usage.StorageDayKey(date)); gerr == nil {
var sd usage.StorageDay
if jerr := json.Unmarshal(raw, &sd); jerr != nil {
log.Printf("console: statements pass: parse %s: %v", usage.StorageDayKey(date), jerr)
} else {
- covered = true
for key, ap := range sd.Apps {
acct := key
if i := strings.IndexByte(key, '/'); i >= 0 {
@@ -368,9 +479,9 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error {
touch(acct)[usage.MeterStorageGBMonth] += (float64(bytesTotal) / 1e9) / float64(daysInMonth)
}
}
- } // missing = skip, uncovered
+ } // missing = skip; sweepUsage already counted the day's coverage
- // Census does not gate coverage (the brief's covered-day sources are
+ // Census does not gate coverage (sweepUsage's covered-day sources are
// metrics/edge/storage only) but still feeds app_months when present.
if raw, _, gerr := s.cfg.Releases.Get(ctx, usage.CensusDayKey(date)); gerr == nil {
var cd usage.CensusDay
@@ -382,11 +493,8 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error {
}
}
}
-
- if covered {
- coveredDays++
- }
}
+ coveredDays := sweep.CoveredDays
// Every account gets a statement this pass, even one with zero usage —
// the accounts page must list it (brief step 3). The union is the
diff --git a/internal/console/economicsview.go b/internal/console/economicsview.go
new file mode 100644
index 000000000..09b71eddc
--- /dev/null
+++ b/internal/console/economicsview.go
@@ -0,0 +1,485 @@
+// Console-side read path for unit economics: assembling what the operator
+// /fleet/economics dashboard (Task 13) renders out of sweepUsage's per-day
+// accumulation (economicspass.go), an active pricing scenario
+// (internal/economics), and the two most recent real bills. Like
+// metricsview.go this is a bucket sweep plus an in-process cache — nothing
+// here writes anything.
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "log"
+ "os"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/economics"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+// economicsCacheTTL/economicsStaleServeCeiling/economicsSweepTimeout mirror
+// metricsview.go's metricsCacheTTL/metricsStaleServeCeiling/
+// metricsSweepTimeout exactly (same 60s freshness window, same 2-minute
+// stale-while-revalidate ceiling) — see those constants' doc comments for
+// the full reasoning, which applies here unchanged: LoadEconomics is called
+// from the same kind of per-request path (the dashboard page) and must not
+// make every request pay for a fresh bucket sweep.
+const (
+ economicsCacheTTL = 60 * time.Second
+ economicsStaleServeCeiling = 2 * time.Minute
+ economicsSweepTimeout = 15 * time.Second
+)
+
+// EconomicsView is one priced period, ready to render.
+type EconomicsView struct {
+ GeneratedAt time.Time
+ Period string
+ Scenario economics.Scenario // active (bucket) or DefaultScenario fallback
+ // ScenarioWarning is set when active.json named a scenario that could
+ // not be loaded/parsed/validated — never set when active.json is simply
+ // absent (a fresh deployment, not a fault; see loadActiveScenario).
+ ScenarioWarning string
+ Scenarios []string // names under economics/scenarios/
+ Accounts []economics.AccountEconomics // priced under Scenario, sorted by CostUSD desc
+ Meters map[string]map[string]float64 // account -> meter (for embedding)
+ Boxes []BoxEconomics
+ Bills []economics.Bill // this month + previous, if present
+ CoveredDays, PeriodDays int
+ DataJSON template.JS // the embedded payload — see economicsPayload
+}
+
+// BoxEconomics is one box, priced under Scenario over the period.
+type BoxEconomics struct {
+ Box string
+ Hours float64
+ CostUSD float64
+ IdleUSD float64
+ RevenueUSD float64 // Σ over tenants: their revenue × (this box's allocated share / their total allocated cost)
+ Utilization float64 // 1 - IdleUSD/CostUSD (0 when CostUSD is 0)
+ EgressMetered bool
+}
+
+// economicsPayload is DataJSON's shape: everything Task 13's inline knob JS
+// needs to redo AllocateBox/PriceAccount client-side when an operator tweaks
+// a scenario knob live, without a server round trip. Field names are frozen
+// by that JS — change both together (economics.go's PriceAccount doc carries
+// the same warning for the pricing formulas themselves). json.Marshal
+// HTML-escapes '<', '>' and '&' by default (unlike an Encoder with
+// SetEscapeHTML(false)) — that default is exactly what keeps this safe to
+// embed inside a literal <script> block, so it must stay in effect: never
+// swap this for an Encoder with escaping turned off.
+type economicsPayload struct {
+ Scenario economics.Scenario `json:"scenario"`
+ Meters map[string]map[string]float64 `json:"meters"`
+ BoxLoads map[string]economics.BoxLoad `json:"boxLoads"`
+ BoxSpecs map[string]economics.BoxSpec `json:"boxSpecs"`
+ Bills []economics.Bill `json:"bills"`
+ CoveredDays int `json:"coveredDays"`
+ PeriodDays int `json:"periodDays"`
+}
+
+// economicsCacheEntry is one period's cached sweep — the metricsCache
+// pattern (metricsview.go:187-338) copied per period rather than once: mu
+// guards the fields and is never held across bucket IO; sweeping/done
+// serialise the IO instead, so a cold sweep for "mtd" never blocks a caller
+// asking for "last". See metricsCache's doc comment for the full reasoning
+// behind every field here — it applies verbatim, just keyed by period.
+type economicsCacheEntry struct {
+ mu sync.Mutex
+ at time.Time
+ view *EconomicsView
+ sweeping bool
+ done chan struct{}
+}
+
+// economicsCache holds one economicsCacheEntry per period ("mtd", "last",
+// "7d") — a small map rather than three named fields, since LoadEconomics
+// normalizes any period string to one of those three before ever touching
+// the cache (normalizeEconomicsPeriod), so the map never grows past three
+// entries. entries itself is guarded by mu; each entry's own mu guards that
+// entry's fields, exactly as metricsCache guards its single set.
+type economicsCache struct {
+ mu sync.Mutex
+ entries map[string]*economicsCacheEntry
+}
+
+// entry returns the stable cache slot for period, creating it on first use.
+func (s *Server) economicsCacheEntry(period string) *economicsCacheEntry {
+ c := &s.economicsCache
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.entries == nil {
+ c.entries = map[string]*economicsCacheEntry{}
+ }
+ e := c.entries[period]
+ if e == nil {
+ e = &economicsCacheEntry{}
+ c.entries[period] = e
+ }
+ return e
+}
+
+// normalizeEconomicsPeriod maps any unrecognized period string to "mtd" —
+// the brief's default, and the shape that keeps the cache map bounded to
+// exactly three entries regardless of what a caller passes.
+func normalizeEconomicsPeriod(period string) string {
+ switch period {
+ case "mtd", "last", "7d":
+ return period
+ default:
+ return "mtd"
+ }
+}
+
+// economicsPeriodBounds turns a normalized period into a [from, to] range,
+// evaluated against now: "mtd" is the first of this month through now;
+// "last" is the WHOLE previous calendar month; "7d" is now-7d through now.
+// Previous-month arithmetic goes through the first of THIS month rather than
+// now.AddDate(0, -1, 0) directly, to dodge time.AddDate's day-overflow
+// behaviour (e.g. Mar 31 minus one month normalizing to Mar 3, not Feb 28) —
+// day 1 never overflows.
+func economicsPeriodBounds(period string, now time.Time) (from, to time.Time) {
+ now = now.UTC()
+ firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
+ switch period {
+ case "last":
+ firstOfLastMonth := firstOfThisMonth.AddDate(0, -1, 0)
+ lastOfLastMonth := firstOfThisMonth.AddDate(0, 0, -1)
+ return firstOfLastMonth, lastOfLastMonth
+ case "7d":
+ return now.AddDate(0, 0, -7), now
+ default: // "mtd"
+ return firstOfThisMonth, now
+ }
+}
+
+// LoadEconomics prices usage over period ("mtd" default, "last", "7d") under
+// the active scenario, caching the result per period in-process — the same
+// three-state freshness logic as LoadMetrics (metricsview.go:257-338):
+// FRESH within economicsCacheTTL returns immediately; STALE-WITHIN-CEILING
+// (past the TTL but within economicsStaleServeCeiling) also returns
+// immediately and kicks a background refresh; COLD or STALE-PAST-THE-CEILING
+// blocks for a real sweep and surfaces its error. See LoadMetrics's doc
+// comment for the full reasoning — it applies here verbatim, just against a
+// per-period cache entry instead of one shared cache.
+func (s *Server) LoadEconomics(ctx context.Context, period string) (*EconomicsView, error) {
+ period = normalizeEconomicsPeriod(period)
+ c := s.economicsCacheEntry(period)
+ for {
+ c.mu.Lock()
+ if !c.at.IsZero() && time.Since(c.at) < economicsCacheTTL {
+ view := c.view
+ c.mu.Unlock()
+ return view, nil
+ }
+ if !c.at.IsZero() && time.Since(c.at) <= economicsStaleServeCeiling {
+ view := c.view
+ if !c.sweeping {
+ s.startBackgroundEconomicsSweep(c, period)
+ }
+ c.mu.Unlock()
+ return view, nil
+ }
+ if c.sweeping {
+ wait := c.done
+ c.mu.Unlock()
+ select {
+ case <-wait:
+ continue
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ }
+ c.sweeping = true
+ c.done = make(chan struct{})
+ done := c.done
+ c.mu.Unlock()
+
+ var (
+ view *EconomicsView
+ err = errors.New("console: economics sweep did not complete")
+ )
+ func() {
+ defer func() {
+ c.mu.Lock()
+ if err == nil {
+ c.at = time.Now()
+ c.view = view
+ }
+ c.sweeping = false
+ close(done)
+ c.mu.Unlock()
+ }()
+ view, err = s.buildEconomicsView(ctx, period)
+ }()
+
+ if err != nil {
+ return nil, err
+ }
+ return view, nil
+ }
+}
+
+// startBackgroundEconomicsSweep mirrors startBackgroundSweep
+// (metricsview.go:361-391): launches buildEconomicsView on its own
+// goroutine, detached (context.Background(), since there is no caller
+// behind it — buildEconomicsView bounds itself with economicsSweepTimeout),
+// with an explicit recover since no net/http per-connection recover sits
+// above it. A failed or panicked background sweep is only logged: no caller
+// is waiting on its error, and the stale cache it leaves untouched is still
+// valid data. Must be called with c.mu held.
+func (s *Server) startBackgroundEconomicsSweep(c *economicsCacheEntry, period string) {
+ c.sweeping = true
+ c.done = make(chan struct{})
+ done := c.done
+ go func() {
+ var (
+ view *EconomicsView
+ err = errors.New("console: background economics sweep did not complete")
+ )
+ defer func() {
+ rec := recover()
+ c.mu.Lock()
+ if err == nil {
+ c.at = time.Now()
+ c.view = view
+ }
+ c.sweeping = false
+ close(done)
+ c.mu.Unlock()
+ switch {
+ case rec != nil:
+ log.Printf("console: background economics sweep panic, cache left at its last good sweep: %v", rec)
+ case err != nil:
+ log.Printf("console: background economics sweep: %v", err)
+ }
+ }()
+ view, err = s.buildEconomicsView(context.Background(), period)
+ }()
+}
+
+// buildEconomicsView is the bucket half of LoadEconomics: sweepUsage over
+// the period's bounds, the active scenario, priced accounts and boxes, the
+// two most recent bills, and the embedded payload — on a context detached
+// from the caller's (WithoutCancel) and bounded by economicsSweepTimeout,
+// exactly like sweepAll (metricsview.go:404-411). It holds no lock.
+func (s *Server) buildEconomicsView(ctx context.Context, period string) (*EconomicsView, error) {
+ sweepCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), economicsSweepTimeout)
+ defer cancel()
+
+ now := time.Now().UTC()
+ from, to := economicsPeriodBounds(period, now)
+
+ sweep, err := s.sweepUsage(sweepCtx, from, to)
+ if err != nil {
+ return nil, fmt.Errorf("console: load economics: %w", err)
+ }
+
+ sc, scenarioWarning := s.loadActiveScenario(sweepCtx)
+
+ scenarioNames, err := s.listScenarioNames(sweepCtx)
+ if err != nil {
+ return nil, fmt.Errorf("console: load economics: %w", err)
+ }
+
+ // Union of accounts to price: every account with usage this period plus
+ // every live console account — the same "an account with zero usage
+ // still gets a row" reasoning statementsPass documents (economicspass.go)
+ // applies to the dashboard just as much as to statements.
+ accounts, err := s.store.Accounts()
+ if err != nil {
+ return nil, fmt.Errorf("console: load economics: %w", err)
+ }
+ universe := map[string]bool{}
+ for acct := range sweep.PerAccount {
+ universe[acct] = true
+ }
+ for _, a := range accounts {
+ universe[a.Sqid] = true
+ }
+
+ // Allocate each box's period cost across the accounts that used it
+ // (economics.AllocateBox), and remember each account's TOTAL allocated
+ // cost across every box it touched — PriceAccount needs that total, and
+ // the per-box revenue attribution below needs each box's SHARE of it.
+ type boxAlloc struct {
+ perAccount map[string]float64
+ idleUSD, totalUSD float64
+ }
+ allocByBox := make(map[string]boxAlloc, len(sweep.PerBox))
+ totalAllocated := map[string]float64{}
+ for box, load := range sweep.PerBox {
+ perAcct, idle, total := economics.AllocateBox(sc.RateCard, sc.Allocation, load)
+ allocByBox[box] = boxAlloc{perAccount: perAcct, idleUSD: idle, totalUSD: total}
+ for acct, usd := range perAcct {
+ totalAllocated[acct] += usd
+ }
+ }
+
+ meters := make(map[string]map[string]float64, len(universe))
+ accountRows := make([]economics.AccountEconomics, 0, len(universe))
+ for acct := range universe {
+ mm := sweep.PerAccount[acct]
+ if mm == nil {
+ mm = map[string]float64{}
+ }
+ meters[acct] = mm
+ accountRows = append(accountRows, economics.PriceAccount(sc, acct, mm, totalAllocated[acct]))
+ }
+ sort.Slice(accountRows, func(i, j int) bool { return accountRows[i].CostUSD > accountRows[j].CostUSD })
+
+ // Revenue attribution to boxes: for each account, boxShare =
+ // allocated[box][acct] / totalAllocated[acct] (0 when the total is 0),
+ // box.RevenueUSD += acct.RevenueUSD * boxShare (brief step 3 formula).
+ boxRows := make([]BoxEconomics, 0, len(sweep.PerBox))
+ for box, load := range sweep.PerBox {
+ alloc := allocByBox[box]
+ var revenue float64
+ for _, ae := range accountRows {
+ total := totalAllocated[ae.Account]
+ if total == 0 {
+ continue
+ }
+ share := alloc.perAccount[ae.Account] / total
+ revenue += ae.RevenueUSD * share
+ }
+ util := 0.0
+ if alloc.totalUSD > 0 {
+ util = 1 - alloc.idleUSD/alloc.totalUSD
+ }
+ boxRows = append(boxRows, BoxEconomics{
+ Box: box,
+ Hours: load.Hours,
+ CostUSD: alloc.totalUSD,
+ IdleUSD: alloc.idleUSD,
+ RevenueUSD: revenue,
+ Utilization: util,
+ EgressMetered: sweep.EgressMetered[box], // absent boxes default false — see usageSweep.EgressMetered
+ })
+ }
+ sort.Slice(boxRows, func(i, j int) bool { return boxRows[i].Box < boxRows[j].Box })
+
+ bills, err := s.loadRecentBills(sweepCtx, now)
+ if err != nil {
+ return nil, fmt.Errorf("console: load economics: %w", err)
+ }
+
+ payload := economicsPayload{
+ Scenario: sc,
+ Meters: meters,
+ BoxLoads: sweep.PerBox,
+ BoxSpecs: sc.RateCard.Boxes,
+ Bills: bills,
+ CoveredDays: sweep.CoveredDays,
+ PeriodDays: sweep.PeriodDays,
+ }
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return nil, fmt.Errorf("console: load economics: marshal payload: %w", err)
+ }
+
+ return &EconomicsView{
+ GeneratedAt: now,
+ Period: period,
+ Scenario: sc,
+ ScenarioWarning: scenarioWarning,
+ Scenarios: scenarioNames,
+ Accounts: accountRows,
+ Meters: meters,
+ Boxes: boxRows,
+ Bills: bills,
+ CoveredDays: sweep.CoveredDays,
+ PeriodDays: sweep.PeriodDays,
+ DataJSON: template.JS(raw),
+ }, nil
+}
+
+// loadActiveScenario resolves economics.ActiveKey -> named scenario ->
+// Validate, per the spec's fallback rule: economics/active.json simply not
+// existing yet is a FRESH DEPLOYMENT, not a fault — DefaultScenario with no
+// warning. Every OTHER failure past that point (active.json unreadable or
+// corrupt, the scenario it names missing, corrupt, or failing Validate) IS a
+// fault worth a visible warning, but still degrades to DefaultScenario
+// rather than failing the whole view — the dashboard must still render.
+func (s *Server) loadActiveScenario(ctx context.Context) (sc economics.Scenario, warning string) {
+ raw, _, err := s.cfg.Releases.Get(ctx, economics.ActiveKey)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return economics.DefaultScenario(), ""
+ }
+ return economics.DefaultScenario(), fmt.Sprintf("read %s: %v; using the default scenario", economics.ActiveKey, err)
+ }
+ var active economics.Active
+ if jerr := json.Unmarshal(raw, &active); jerr != nil {
+ return economics.DefaultScenario(), fmt.Sprintf("%s is corrupt: %v; using the default scenario", economics.ActiveKey, jerr)
+ }
+
+ scRaw, _, err := s.cfg.Releases.Get(ctx, economics.ScenarioKey(active.Name))
+ if err != nil {
+ return economics.DefaultScenario(), fmt.Sprintf("active scenario %q: %v; using the default scenario", active.Name, err)
+ }
+ var scenario economics.Scenario
+ if jerr := json.Unmarshal(scRaw, &scenario); jerr != nil {
+ return economics.DefaultScenario(), fmt.Sprintf("active scenario %q is corrupt: %v; using the default scenario", active.Name, jerr)
+ }
+ if verr := scenario.Validate(); verr != nil {
+ return economics.DefaultScenario(), fmt.Sprintf("active scenario %q is invalid: %v; using the default scenario", active.Name, verr)
+ }
+ return scenario, ""
+}
+
+// listScenarioNames lists economics/scenarios/*.json and strips both the
+// prefix and the extension — the flat set of names a scenario picker (Task
+// 13) offers, alongside "default" (DefaultScenario is never itself stored).
+func (s *Server) listScenarioNames(ctx context.Context) ([]string, error) {
+ keys, err := s.cfg.Releases.List(ctx, economics.ScenariosPrefix)
+ if err != nil {
+ return nil, fmt.Errorf("list %s: %w", economics.ScenariosPrefix, err)
+ }
+ names := make([]string, 0, len(keys))
+ for _, key := range keys {
+ rest := strings.TrimPrefix(key, economics.ScenariosPrefix)
+ name := strings.TrimSuffix(rest, ".json")
+ if name == "" || name == rest || strings.Contains(name, "/") {
+ continue // defensive: skip anything not a flat "<name>.json" scenario file
+ }
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ return names, nil
+}
+
+// loadRecentBills reads economics.BillKey for now's month and the one
+// before it — regardless of the requested period, per the brief — and
+// simply omits a month with no bill object rather than erroring: a missing
+// bill is a normal, ongoing state (this month's hasn't been pulled yet),
+// not a fault.
+func (s *Server) loadRecentBills(ctx context.Context, now time.Time) ([]economics.Bill, error) {
+ firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
+ firstOfLastMonth := firstOfThisMonth.AddDate(0, -1, 0)
+ months := []string{usage.MonthOf(now), usage.MonthOf(firstOfLastMonth)}
+
+ var bills []economics.Bill
+ for _, month := range months {
+ raw, _, err := s.cfg.Releases.Get(ctx, economics.BillKey(month))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ return nil, fmt.Errorf("bill %s: %w", month, err)
+ }
+ var b economics.Bill
+ if jerr := json.Unmarshal(raw, &b); jerr != nil {
+ log.Printf("console: load economics: parse %s: %v", economics.BillKey(month), jerr)
+ continue
+ }
+ bills = append(bills, b)
+ }
+ return bills, nil
+}
diff --git a/internal/console/economicsview_test.go b/internal/console/economicsview_test.go
new file mode 100644
index 000000000..26151a0e0
--- /dev/null
+++ b/internal/console/economicsview_test.go
@@ -0,0 +1,171 @@
+package console
+
+import (
+ "context"
+ "math"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/economics"
+ "github.com/carlosframework/platform/internal/metrics"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+// TestLoadEconomicsGolden reuses TestStatementsPassGolden's golden objects
+// (economicspass_test.go) — one box, one route, cpu 0.5/mem 1e9 avg over 60
+// samples in one hour, 2GB/4000req edge traffic for the same route's
+// account — plus a named scenario ("test") set active, and asserts
+// LoadEconomics prices it: the active scenario loads by name, the account
+// shows up with real cost, box1's allocation reconstructs its own cost via
+// the very same economics.AllocateBox call used for the golden fixture, and
+// the embedded payload names its "meters" field for Task 13's inline JS.
+//
+// Dates are relative to the real wall clock (today), not a fixed 2026-08
+// date, because LoadEconomics has no `now` parameter — it reads real time —
+// mirroring how metricsview_test.go seeds LoadMetrics fixtures.
+func TestLoadEconomicsGolden(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance",
+ Status: "ready", App: fixtureApp, Version: "v1"}); err != nil {
+ t.Fatal(err)
+ }
+
+ date := usage.DayOf(time.Now())
+ cpu, mem := 0.5, 1e9
+ roll := metrics.DayRollup{V: 1, Box: "box1", Date: date, Hours: map[string]metrics.HourAgg{
+ "09": {Units: map[string]map[string]metrics.Agg{
+ "carlos-app@a.example.com.service": {
+ "cpu": {Avg: cpu, N: 60},
+ "mem_bytes": {Avg: mem, N: 60},
+ },
+ }},
+ }}
+ putStoreJSON(t, fx.rel, metrics.DayKey("box1", date), roll)
+
+ putStoreJSON(t, fx.rel, usage.EdgeDayKey("box1", date), usage.EdgeDay{
+ V: 1, Box: "box1", Date: date,
+ Hours: map[string]map[string]usage.EdgeRow{"09": {
+ "a.example.com": {Account: fixtureAcct, App: fixtureApp, Requests: 4000, BytesOut: 2e9},
+ }},
+ })
+
+ sc := economics.DefaultScenario()
+ sc.Name = "test"
+ putStoreJSON(t, fx.rel, economics.ScenarioKey("test"), sc)
+ putStoreJSON(t, fx.rel, economics.ActiveKey, economics.Active{Schema: 1, Name: "test"})
+
+ view, err := fx.srv.LoadEconomics(ctx, "mtd")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if view.Scenario.Name != "test" {
+ t.Fatalf("Scenario.Name = %q, want %q", view.Scenario.Name, "test")
+ }
+ if view.ScenarioWarning != "" {
+ t.Fatalf("ScenarioWarning = %q, want empty (a named, valid active scenario)", view.ScenarioWarning)
+ }
+
+ var acctRow *economics.AccountEconomics
+ for i := range view.Accounts {
+ if view.Accounts[i].Account == fixtureAcct {
+ acctRow = &view.Accounts[i]
+ }
+ }
+ if acctRow == nil {
+ t.Fatalf("no AccountEconomics row for %q in %+v", fixtureAcct, view.Accounts)
+ }
+ if acctRow.CostUSD <= 0 {
+ t.Fatalf("acctRow.CostUSD = %v, want > 0", acctRow.CostUSD)
+ }
+
+ var boxRow *BoxEconomics
+ for i := range view.Boxes {
+ if view.Boxes[i].Box == "box1" {
+ boxRow = &view.Boxes[i]
+ }
+ }
+ if boxRow == nil {
+ t.Fatalf("no BoxEconomics row for box1 in %+v", view.Boxes)
+ }
+ if boxRow.Hours != 1 {
+ t.Fatalf("boxRow.Hours = %v, want 1 (one hour bucket, \"09\", in the golden day)", boxRow.Hours)
+ }
+
+ // Independently recompute the same box's allocation via
+ // economics.AllocateBox with hand-built load matching the golden
+ // fixture's cpu/mem averages, and confirm LoadEconomics's box row agrees
+ // — idle + allocated reconstructs the box's own total cost.
+ load := economics.BoxLoad{
+ Box: "box1",
+ Hours: 1,
+ ByAccount: map[string]economics.BoxAccountLoad{
+ fixtureAcct: {CPUCoreHours: 0.5, MemGBHours: 1.0},
+ },
+ }
+ perAcct, idleUSD, totalUSD := economics.AllocateBox(sc.RateCard, sc.Allocation, load)
+ allocated := perAcct[fixtureAcct]
+ if math.Abs((idleUSD+allocated)-totalUSD) > 1e-9 {
+ t.Fatalf("idle+allocated = %v, want totalUSD %v", idleUSD+allocated, totalUSD)
+ }
+ if math.Abs(boxRow.CostUSD-totalUSD) > 1e-9 {
+ t.Fatalf("boxRow.CostUSD = %v, want %v", boxRow.CostUSD, totalUSD)
+ }
+ if math.Abs(boxRow.IdleUSD-idleUSD) > 1e-9 {
+ t.Fatalf("boxRow.IdleUSD = %v, want %v", boxRow.IdleUSD, idleUSD)
+ }
+
+ if !strings.Contains(string(view.DataJSON), "meters") {
+ t.Fatalf("DataJSON missing %q: %s", "meters", view.DataJSON)
+ }
+}
+
+// TestLoadEconomicsWarnsOnMissingNamedScenario: active.json naming a
+// scenario that doesn't exist under economics/scenarios/ is a WARNING, not
+// silence — the spec distinguishes "no active.json at all" (fresh
+// deployment, silent DefaultScenario) from "active.json points at
+// something broken" (visible warning, still DefaultScenario so the page
+// still renders).
+func TestLoadEconomicsWarnsOnMissingNamedScenario(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+ putStoreJSON(t, fx.rel, economics.ActiveKey, economics.Active{Schema: 1, Name: "missing"})
+
+ view, err := fx.srv.LoadEconomics(ctx, "mtd")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if view.Scenario.Name != economics.DefaultScenario().Name {
+ t.Fatalf("Scenario.Name = %q, want default %q", view.Scenario.Name, economics.DefaultScenario().Name)
+ }
+ if view.ScenarioWarning == "" {
+ t.Fatal("ScenarioWarning is empty, want a warning naming the unloadable scenario")
+ }
+}
+
+// TestLoadEconomicsDefaultsSilentlyWithNoActiveFile: a fresh deployment with
+// no economics/active.json at all must fall back to DefaultScenario with NO
+// warning — that absence is the expected initial state, not a fault.
+func TestLoadEconomicsDefaultsSilentlyWithNoActiveFile(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+
+ view, err := fx.srv.LoadEconomics(ctx, "mtd")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if view.Scenario.Name != economics.DefaultScenario().Name {
+ t.Fatalf("Scenario.Name = %q, want default %q", view.Scenario.Name, economics.DefaultScenario().Name)
+ }
+ if view.ScenarioWarning != "" {
+ t.Fatalf("ScenarioWarning = %q, want empty (missing active.json is a fresh deployment, not a fault)", view.ScenarioWarning)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 6eb745a45..3ad810ba6 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -268,6 +268,11 @@ type Server struct {
// dashboard and every owner's app-metrics page don't each re-sweep 48h of
// chunks per request.
metricsCache metricsCache
+ // economicsCache holds LoadEconomics's last successful sweep per period
+ // ("mtd", "last", "7d" — economicsview.go). Same shape as metricsCache,
+ // keyed by period so a cold sweep for one period never blocks a caller
+ // asking for another.
+ economicsCache economicsCache
// storeSizer is the storage pass's test seam for sizing a provisioned
// tenant object store: nil means "use the real S3 sizer"
// (sizeProvisionedStore, economicspass.go); tests inject a fake so a