usage: count statement coverage per source, not per day
CoveredDays marks a day covered when any of metrics, edge or storage produced an object, so a statement can report full coverage while an entire day of egress is missing. Fine for modelling, wrong for billing. Statements now carry SourceDays, and MeterComplete answers whether one meter's source reported every day of the month. A statement written before this field reads as unknown rather than complete.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cEe5te3cyEcdF3JL2rN5M
5 files changed,
+294
−2
internal/console/economicspass.go+51 −0internal/console/economicspass_test.go+142 −0internal/usage/passes_test.go+47 −0internal/usage/statement.go+21 −2internal/usage/usage.go+33 −0
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go| index e42932060..03e12a34a 100644 |
| --- a/internal/console/economicspass.go |
| +++ b/internal/console/economicspass.go |
| @@ -249,6 +249,12 @@ type usageSweep struct { |
| // those contributes nothing, uncovered, never a zero standing in for |
| // missing data (package usage's "modelling, not invoice" posture). |
| CoveredDays, PeriodDays int |
| + // SourceDays counts, per usage.Source*, the days in [from, to] on which |
| + // EVERY discovered box of that source produced its object. CoveredDays |
| + // asks "did anything report"; this asks "did nothing go missing", which |
| + // is the only version a bill can rest on — one healthy box must never |
| + // vouch for a silent one. |
| + SourceDays map[string]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 |
| @@ -309,6 +315,7 @@ func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []re |
| PerAccount: map[string]map[string]float64{}, |
| PerBox: map[string]economics.BoxLoad{}, |
| BoxDays: map[string]int{}, |
| + SourceDays: map[string]int{}, |
| EgressMetered: map[string]bool{}, |
| } |
| // A box is metered for egress the moment it has EVER written a day — |
| @@ -340,6 +347,8 @@ func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []re |
| for d := fromDay; !d.After(toDay); d = d.AddDate(0, 0, 1) { |
| date := usage.DayOf(d) |
| covered := false |
| + metricsDays, edgeDays := 0, 0 |
| + storageOK, censusOK := false, false |
| for _, boxPrefix := range metricsBoxes { |
| box := strings.TrimSuffix(strings.TrimPrefix(boxPrefix, metricsBoxesPrefix), "/") |
| @@ -353,6 +362,7 @@ func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []re |
| continue |
| } |
| covered = true |
| + metricsDays++ |
| sweep.BoxDays[box]++ |
| bl := boxLoad(box) |
| bl.Hours += float64(len(roll.Hours)) |
| @@ -396,6 +406,7 @@ func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []re |
| continue |
| } |
| covered = true |
| + edgeDays++ |
| for _, rows := range ed.Hours { |
| for _, row := range rows { |
| acct := row.Account |
| @@ -427,9 +438,47 @@ func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []re |
| var sd usage.StorageDay |
| if jerr := json.Unmarshal(raw, &sd); jerr == nil { |
| covered = true |
| + storageOK = true |
| + for _, as := range sd.Apps { |
| + if as.StoreUnswept { |
| + // A store the pass could not size contributes a |
| + // zero that reads as "no bytes". The day is |
| + // covered for modelling, never complete for |
| + // billing. |
| + storageOK = false |
| + break |
| + } |
| + } |
| + } |
| + } |
| + |
| + // Census deliberately does NOT set `covered`: the legacy covered-day |
| + // sources are metrics/edge/storage only (foldStorageAndCensus says so |
| + // at economicspass.go:565-566), and that number reaches the live |
| + // economics view. A month of census snapshots alone must not turn |
| + // zero covered days into full coverage. It must also DECODE, not |
| + // merely exist — a malformed census is not a complete one. |
| + if raw, _, gerr := s.cfg.Releases.Get(ctx, usage.CensusDayKey(date)); gerr == nil { |
| + var cd usage.CensusDay |
| + if jerr := json.Unmarshal(raw, &cd); jerr == nil { |
| + censusOK = true |
| } |
| } |
| + // Strict: every discovered box must have reported this day. |
| + if len(metricsBoxes) > 0 && metricsDays == len(metricsBoxes) { |
| + sweep.SourceDays[usage.SourceMetrics]++ |
| + } |
| + if len(edgeBoxes) > 0 && edgeDays == len(edgeBoxes) { |
| + sweep.SourceDays[usage.SourceEdge]++ |
| + } |
| + if storageOK { |
| + sweep.SourceDays[usage.SourceStorage]++ |
| + } |
| + if censusOK { |
| + sweep.SourceDays[usage.SourceCensus]++ |
| + } |
| + |
| if covered { |
| sweep.CoveredDays++ |
| } |
| @@ -774,6 +823,7 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error { |
| } |
| } |
| coveredDays := sweep.CoveredDays |
| + sourceDays := sweep.SourceDays |
| // 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 |
| @@ -809,6 +859,7 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error { |
| Month: month, |
| Meters: mm, |
| CoveredDays: coveredDays, |
| + SourceDays: sourceDays, |
| DaysInMonth: daysInMonth, |
| UpdatedAt: nowUTC, |
| } |
diff --git a/internal/console/economicspass_test.go b/internal/console/economicspass_test.go| index 214690748..97a0cbc12 100644 |
| --- a/internal/console/economicspass_test.go |
| +++ b/internal/console/economicspass_test.go |
| @@ -698,3 +698,145 @@ func TestEconomicsTierDoor(t *testing.T) { |
| t.Fatalf("account tier after operator POST = %q, want %q", got, want) |
| } |
| } |
| + |
| +// A day with only an edge object must count for edge and NOT for metrics, |
| +// so a statement can tell "we saw egress every day" from "we saw something |
| +// every day". |
| +func TestStatementsPassCountsSourcesSeparately(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Date(2026, 8, 3, 6, 0, 0, 0, time.UTC) |
| + |
| + 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) |
| + } |
| + reg.Close() |
| + |
| + // 1 Aug: metrics only. 2 Aug: edge only. 3 Aug: neither. |
| + putStoreJSON(t, fx.rel, metrics.DayKey("box1", "2026-08-01"), metrics.DayRollup{}) |
| + putStoreJSON(t, fx.rel, usage.EdgeDayKey("box1", "2026-08-02"), usage.EdgeDay{}) |
| + |
| + if err := fx.srv.statementsPass(ctx, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + raw, _, err := fx.rel.Get(ctx, usage.StatementKey(fixtureAcct, "2026-08")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var st usage.Statement |
| + if err := json.Unmarshal(raw, &st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := st.SourceDays[usage.SourceMetrics]; got != 1 { |
| + t.Errorf("metrics source days = %d, want 1", got) |
| + } |
| + if got := st.SourceDays[usage.SourceEdge]; got != 1 { |
| + t.Errorf("edge source days = %d, want 1", got) |
| + } |
| + if st.CoveredDays != 2 { |
| + t.Errorf("CoveredDays = %d, want 2 (either source counts)", st.CoveredDays) |
| + } |
| + if st.MeterComplete(usage.MeterEgressGB) { |
| + t.Error("egress must not be complete with 1 of 3 edge days") |
| + } |
| +} |
| + |
| +// One box reporting must never vouch for a box that went silent. |
| +func TestStatementsPassSourceDaysNeedsEveryBox(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Date(2026, 8, 1, 23, 0, 0, 0, time.UTC) |
| + |
| + // Two edge boxes are discoverable; only one reports 1 August. |
| + putStoreJSON(t, fx.rel, usage.EdgeDayKey("box1", "2026-07-31"), usage.EdgeDay{}) |
| + putStoreJSON(t, fx.rel, usage.EdgeDayKey("box2", "2026-07-31"), usage.EdgeDay{}) |
| + putStoreJSON(t, fx.rel, usage.EdgeDayKey("box1", "2026-08-01"), usage.EdgeDay{}) |
| + |
| + if err := fx.srv.statementsPass(ctx, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := fx.rel.Get(ctx, usage.StatementKey(fixtureAcct, "2026-08")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var st usage.Statement |
| + if err := json.Unmarshal(raw, &st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := st.SourceDays[usage.SourceEdge]; got != 0 { |
| + t.Errorf("edge source days = %d, want 0 — box2 reported nothing on 1 Aug", got) |
| + } |
| + if st.CoveredDays != 1 { |
| + t.Errorf("CoveredDays = %d, want 1 — box1 alone still covers for modelling", st.CoveredDays) |
| + } |
| +} |
| + |
| +// A storage day that could not size a tenant store is covered, never complete. |
| +func TestStatementsPassUnsweptStorageIsNotComplete(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Date(2026, 8, 1, 23, 0, 0, 0, time.UTC) |
| + |
| + putStoreJSON(t, fx.rel, usage.StorageDayKey("2026-08-01"), usage.StorageDay{ |
| + V: 1, Date: "2026-08-01", |
| + Apps: map[string]usage.AppStorage{ |
| + fixtureAcct + "/" + fixtureApp: {BucketBytes: 1000, StoreUnswept: true}, |
| + }, |
| + }) |
| + |
| + if err := fx.srv.statementsPass(ctx, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := fx.rel.Get(ctx, usage.StatementKey(fixtureAcct, "2026-08")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var st usage.Statement |
| + if err := json.Unmarshal(raw, &st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := st.SourceDays[usage.SourceStorage]; got != 0 { |
| + t.Errorf("storage source days = %d, want 0 — a store went unswept", got) |
| + } |
| +} |
| + |
| +// A census snapshot is not a measurement. Census must feed its own source |
| +// count without touching CoveredDays, which foldStorageAndCensus documents |
| +// as metrics/edge/storage only (economicspass.go:516-517) and which reaches |
| +// the live economics view. |
| +func TestStatementsPassCensusDoesNotCountAsCoverage(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Date(2026, 8, 1, 23, 0, 0, 0, time.UTC) |
| + |
| + putStoreJSON(t, fx.rel, usage.CensusDayKey("2026-08-01"), usage.CensusDay{ |
| + V: 1, Date: "2026-08-01", |
| + Accounts: map[string]usage.CensusAccount{fixtureAcct: {Apps: 1, Routes: 1}}, |
| + }) |
| + |
| + if err := fx.srv.statementsPass(ctx, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := fx.rel.Get(ctx, usage.StatementKey(fixtureAcct, "2026-08")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var st usage.Statement |
| + if err := json.Unmarshal(raw, &st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st.CoveredDays != 0 { |
| + t.Errorf("CoveredDays = %d, want 0 — a census alone measures nothing", st.CoveredDays) |
| + } |
| + if got := st.SourceDays[usage.SourceCensus]; got != 1 { |
| + t.Errorf("census source days = %d, want 1", got) |
| + } |
| +} |
diff --git a/internal/usage/passes_test.go b/internal/usage/passes_test.go| index 2beb0d303..5a68344c3 100644 |
| --- a/internal/usage/passes_test.go |
| +++ b/internal/usage/passes_test.go |
| @@ -68,3 +68,50 @@ func TestUpsertStatementReplaces(t *testing.T) { |
| t.Fatalf("statement = %+v", got) |
| } |
| } |
| + |
| +func TestMeterSource(t *testing.T) { |
| + cases := map[string]string{ |
| + MeterCPUCoreHours: SourceMetrics, |
| + MeterMemGBHours: SourceMetrics, |
| + MeterInstanceHours: SourceMetrics, |
| + // Fleet box-hours come from fleetMeters, which reads per-fleet |
| + // objects this sweep never looks at. No source here means never |
| + // complete, which is the honest answer until they get their own. |
| + MeterFleetBoxHoursServer: "", |
| + MeterFleetBoxHoursDevice: "", |
| + MeterEgressGB: SourceEdge, |
| + MeterRequests1k: SourceEdge, |
| + MeterBackhaulGB: SourceEdge, |
| + MeterBackhaulRequests1k: SourceEdge, |
| + MeterStorageGBMonth: SourceStorage, |
| + MeterAppMonths: SourceCensus, |
| + "not_a_meter": "", |
| + } |
| + for meter, want := range cases { |
| + if got := MeterSource(meter); got != want { |
| + t.Errorf("MeterSource(%q) = %q, want %q", meter, got, want) |
| + } |
| + } |
| +} |
| + |
| +func TestMeterCompleteNeedsEveryDay(t *testing.T) { |
| + full := Statement{ |
| + DaysInMonth: 31, |
| + SourceDays: map[string]int{SourceMetrics: 31, SourceEdge: 30}, |
| + } |
| + if !full.MeterComplete(MeterInstanceHours) { |
| + t.Error("instance_hours with 31/31 metrics days should be complete") |
| + } |
| + if full.MeterComplete(MeterEgressGB) { |
| + t.Error("egress_gb with 30/31 edge days must NOT be complete") |
| + } |
| + if full.MeterComplete("not_a_meter") { |
| + t.Error("a meter with no source is never complete") |
| + } |
| + // A statement written before this change has no SourceDays at all. |
| + // Unknown must read as incomplete, never as complete. |
| + old := Statement{DaysInMonth: 31, CoveredDays: 31} |
| + if old.MeterComplete(MeterEgressGB) { |
| + t.Error("a statement with no SourceDays must not report complete") |
| + } |
| +} |
diff --git a/internal/usage/statement.go b/internal/usage/statement.go| index 7822bb876..8a0cdb6b3 100644 |
| --- a/internal/usage/statement.go |
| +++ b/internal/usage/statement.go |
| @@ -22,8 +22,15 @@ type Statement struct { |
| Month string `json:"month"` // "2006-01" |
| Meters map[string]float64 `json:"meters"` |
| CoveredDays int `json:"covered_days"` |
| - DaysInMonth int `json:"days_in_month"` |
| - UpdatedAt time.Time `json:"updated_at"` |
| + // SourceDays counts, per collection source, how many UTC days of the |
| + // month produced that source's object. CoveredDays answers "did any |
| + // source report", which is the modelling question; this answers "did |
| + // THIS meter's source report every day", which is the billing one. |
| + // Absent (a statement written before this field existed) reads as |
| + // unknown, never as complete. |
| + SourceDays map[string]int `json:"source_days,omitempty"` |
| + DaysInMonth int `json:"days_in_month"` |
| + UpdatedAt time.Time `json:"updated_at"` |
| } |
| // StatementKey is the bucket key for an account's month statement. |
| @@ -76,3 +83,15 @@ func CreateDayObject(ctx context.Context, s release.Store, key string, v any) (b |
| } |
| return true, nil |
| } |
| + |
| +// MeterComplete reports whether every day of the statement's month produced |
| +// the source this meter derives from. False when the source is unknown, when |
| +// the statement predates SourceDays, or when any day is missing — the |
| +// package's "a missing source is never a zero" posture, applied to billing. |
| +func (st Statement) MeterComplete(meter string) bool { |
| + src := MeterSource(meter) |
| + if src == "" || st.DaysInMonth <= 0 || st.SourceDays == nil { |
| + return false |
| + } |
| + return st.SourceDays[src] >= st.DaysInMonth |
| +} |
diff --git a/internal/usage/usage.go b/internal/usage/usage.go| index dd0ee44a9..3b3d2455a 100644 |
| --- a/internal/usage/usage.go |
| +++ b/internal/usage/usage.go |
| @@ -38,6 +38,39 @@ const ( |
| MeterBackhaulRequests1k = "backhaul_requests_1k" |
| ) |
| +// Collection sources — the pass-visible daily object a meter's value is |
| +// derived from. Billing needs to know that a specific meter saw every day |
| +// of a month, which CoveredDays (any source, any box) cannot answer. |
| +const ( |
| + SourceMetrics = "metrics" // metrics/<box>/days/<date>.json |
| + SourceEdge = "edge" // usage/edge/<box>/days/<date>.json |
| + SourceStorage = "storage" // usage/storage/<date>.json |
| + SourceCensus = "census" // usage/census/<date>.json |
| +) |
| + |
| +// MeterSource names the collection source a meter derives from, or "" for a |
| +// meter whose completeness this sweep cannot vouch for. |
| +// |
| +// The fleet box-hour meters return "" deliberately: fleetMeters |
| +// (console/economicspass.go) derives them from per-fleet objects that |
| +// sweepUsage never reads, so platform metrics coverage says nothing about |
| +// them. Claiming SourceMetrics would let complete platform data vouch for |
| +// fleet days that never arrived. They need their own coverage signal, which |
| +// belongs with the plan that bills fleet boxes. |
| +func MeterSource(meter string) string { |
| + switch meter { |
| + case MeterCPUCoreHours, MeterMemGBHours, MeterInstanceHours: |
| + return SourceMetrics |
| + case MeterEgressGB, MeterRequests1k, MeterBackhaulGB, MeterBackhaulRequests1k: |
| + return SourceEdge |
| + case MeterStorageGBMonth: |
| + return SourceStorage |
| + case MeterAppMonths: |
| + return SourceCensus |
| + } |
| + return "" |
| +} |
| + |
| // DayOf is the UTC date label used in every usage key. |
| func DayOf(t time.Time) string { return t.UTC().Format("2006-01-02") } |