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>
4 files changed,
+824
−55
internal/console/economicspass.go+163 −55internal/console/economicsview.go+485 −0internal/console/economicsview_test.go+171 −0internal/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 |