Merge pull request #307 from carlosframework/hop-reader
economics: what backhaul costs us — meters, pages, API and CLI (#288)
14 files changed,
+433
−15
docs/site/cli.md+9 −0docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md+11 −0economics.go+107 −2economics_backhaul_test.go+28 −0internal/console/economicsbackhaul_test.go+116 −0internal/console/economicspass.go+104 −0internal/console/economicspass_test.go+5 −1internal/console/server.go+7 −0internal/console/templates/economics.html+13 −3internal/console/templates/economics_accounts.html+2 −0internal/economics/compute.go+7 −1internal/economics/scenario.go+10 −1internal/usage/usage.go+12 −6main.go+2 −1
diff --git a/docs/site/cli.md b/docs/site/cli.md| index ee9a45caf..eb1fac4f1 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -617,6 +617,15 @@ Operator only, and the console answers everyone else with the same bare 404 |
| it gives an unknown route. A "not found" here almost always means your |
| account lacks the fleet-operator bit, not that you typed the path wrong. |
| +`carlos economics backhaul` shows what serving through region edges costs |
| +the platform this period: the part of each account's egress that was |
| +backhauled from its home box, priced at the rate card's inter-region |
| +$/GB and absorbed — it appears in nobody's bill. |
| + |
| +```sh |
| +carlos economics backhaul -period mtd |
| +``` |
| + |
| ### carlos status |
| `-live` prints what every box in the fleet last reported about itself: |
diff --git a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md| index e4d111856..70efa2bd9 100644 |
| --- a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md |
| +++ b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md |
| @@ -391,6 +391,17 @@ decision (price backhauled bytes, or absorb them as a platform cost of |
| any-edge serving) can be made later on real data. Default taken tonight: |
| **absorbed**. |
| +*Amendment 2026-08-26 (#284 ruled, #288 built).* Paul's ruling: absorb, |
| +keep the data on what it costs us. The counters are no longer dormant: |
| +the usage sweep folds the hop subset into `backhaul_gb` and |
| +`backhaul_requests_1k`, which ride in every account statement as data; |
| +`RateCard.InterRegionGBUSD` prices the extra home→edge leg (the home |
| +box's metered bytes are already egress) as a model line on the |
| +economics page, in the model total and in NO account's cost, revenue or |
| +bill — `PriceAccount` ignores both meters, pinned by test. Read via the |
| +economics pages, `GET /api/economics/backhaul` and `carlos economics |
| +backhaul`. |
| + |
| *Rollout.* Must be rolled to every box of a deployment BEFORE that |
| deployment arms a second edge — the home box needs the new binary to |
| annotate hops, the senders need the cap. It slots ahead of the steering |
diff --git a/economics.go b/economics.go| index 54221e8fa..9c99ecb61 100644 |
| --- a/economics.go |
| +++ b/economics.go |
| @@ -16,9 +16,11 @@ import ( |
| "errors" |
| "flag" |
| "fmt" |
| + "io" |
| "log" |
| "math" |
| "net/http" |
| + "net/url" |
| "os" |
| "sort" |
| "strconv" |
| @@ -27,6 +29,7 @@ import ( |
| ) |
| const economicsUsage = `usage: carlos economics bill -month <YYYY-MM> -total <usd> [-line Service=usd ...] [-console <origin>] |
| + carlos economics backhaul [-period mtd|last|7d] [-console <origin>] |
| Records one month's AWS bill for the economics dashboard. Operator only: the |
| console answers a non-operator's token with the same bare 404 it gives an |
| @@ -122,11 +125,113 @@ func validMonth(month string) error { |
| // exit 2), not the flag-validation log.Fatal (exit 1) every verb below this |
| // dispatch uses for its OWN bad arguments. |
| func runEconomics(args []string) { |
| - if len(args) == 0 || args[0] != "bill" { |
| + if len(args) == 0 { |
| fmt.Fprint(os.Stderr, economicsUsage) |
| os.Exit(2) |
| } |
| - runEconomicsBill(args[1:]) |
| + switch args[0] { |
| + case "bill": |
| + runEconomicsBill(args[1:]) |
| + case "backhaul": |
| + runEconomicsBackhaul(args[1:]) |
| + default: |
| + fmt.Fprint(os.Stderr, economicsUsage) |
| + os.Exit(2) |
| + } |
| +} |
| + |
| +const economicsBackhaulUsage = `usage: carlos economics backhaul [-period mtd|last|7d] [-console <origin>] |
| + |
| +Shows what serving through region edges costs the platform this period: |
| +the part of every account's egress that was backhauled from its home box |
| +(usage.EdgeRow's hop subset — inside the egress totals, never on top), |
| +priced at the rate card's inter-region $/GB (the extra leg; the home box's |
| +bytes are already egress) and ABSORBED — it is in nobody's bill (#284). |
| +Operator only; a non-operator's token gets the same bare 404 an unknown |
| +route gets. |
| +` |
| + |
| +type backhaulReport struct { |
| + Period string `json:"period"` |
| + CoveredDays int `json:"covered_days"` |
| + PeriodDays int `json:"period_days"` |
| + EgressGB float64 `json:"egress_gb"` |
| + BackhaulGB float64 `json:"backhaul_gb"` |
| + BackhaulReq1k float64 `json:"backhaul_requests_1k"` |
| + ShareOfEgress float64 `json:"share_of_egress"` |
| + InterRegionGBUSD float64 `json:"inter_region_gb_usd"` |
| + AbsorbedUSD float64 `json:"absorbed_usd"` |
| + Accounts []backhaulAccount `json:"accounts"` |
| +} |
| + |
| +type backhaulAccount struct { |
| + Account string `json:"account"` |
| + Name string `json:"name,omitempty"` |
| + EgressGB float64 `json:"egress_gb"` |
| + BackhaulGB float64 `json:"backhaul_gb"` |
| + BackhaulReq1k float64 `json:"backhaul_requests_1k"` |
| + ShareOfEgress float64 `json:"share_of_egress"` |
| + AbsorbedUSD float64 `json:"absorbed_usd"` |
| +} |
| + |
| +func (s *apiSession) economicsBackhaul(ctx context.Context, period string) (backhaulReport, error) { |
| + path := "/api/economics/backhaul" |
| + if period != "" { |
| + path += "?period=" + url.QueryEscape(period) |
| + } |
| + status, body, err := s.getJSON(ctx, path) |
| + if err != nil { |
| + return backhaulReport{}, err |
| + } |
| + if status != http.StatusOK { |
| + if status == http.StatusNotFound { |
| + return backhaulReport{}, errors.New("not found — this needs the fleet-operator bit on your account, " + |
| + "or this console predates `carlos economics backhaul` (deploy a console built from current main)") |
| + } |
| + return backhaulReport{}, apiErrorFromBody(status, body) |
| + } |
| + var out backhaulReport |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return backhaulReport{}, fmt.Errorf("economics backhaul: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| +func runEconomicsBackhaul(args []string) { |
| + fs := flag.NewFlagSet("economics backhaul", flag.ExitOnError) |
| + period := fs.String("period", "", "mtd (default), last, or 7d") |
| + consoleFlag := fs.String("console", "", "console to ask (default: the only logged-in one)") |
| + fs.Usage = func() { fmt.Fprint(os.Stderr, economicsBackhaulUsage) } |
| + fs.Parse(args) |
| + sess, err := resolveMode(*consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("carlos economics backhaul works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Unset the store env and run: carlos auth login -console <url>.") |
| + } |
| + printUsing(stdout, sess) |
| + rep, err := sess.economicsBackhaul(context.Background(), *period) |
| + if err != nil { |
| + log.Fatalf("economics backhaul: %v", err) |
| + } |
| + printBackhaulReport(stdout, rep) |
| +} |
| + |
| +func printBackhaulReport(w io.Writer, rep backhaulReport) { |
| + fmt.Fprintf(w, "backhaul %s (%d/%d days covered): %.2f GB of %.2f GB egress (%.1f%%), %.1fk requests — inter-region leg ≈ $%.2f at $%.3f/GB, absorbed\n", |
| + rep.Period, rep.CoveredDays, rep.PeriodDays, rep.BackhaulGB, rep.EgressGB, rep.ShareOfEgress*100, rep.BackhaulReq1k, rep.AbsorbedUSD, rep.InterRegionGBUSD) |
| + if len(rep.Accounts) == 0 { |
| + fmt.Fprintln(w, "no account backhauled anything this period") |
| + return |
| + } |
| + tw := newTable(w) |
| + fmt.Fprintln(tw, "ACCOUNT\tNAME\tEGRESS GB\tBACKHAUL GB\tSHARE\tREQ (k)\tABSORBED") |
| + for _, a := range rep.Accounts { |
| + fmt.Fprintf(tw, "%s\t%s\t%.2f\t%.2f\t%.1f%%\t%.1f\t$%.2f\n", a.Account, a.Name, a.EgressGB, a.BackhaulGB, a.ShareOfEgress*100, a.BackhaulReq1k, a.AbsorbedUSD) |
| + } |
| + tw.Flush() |
| } |
| // economicsBillResult mirrors handleAPIEconomicsBill's success shape: the |
diff --git a/economics_backhaul_test.go b/economics_backhaul_test.go| new file mode 100644 |
| index 000000000..1077f86ae |
| --- /dev/null |
| +++ b/economics_backhaul_test.go |
| @@ -0,0 +1,28 @@ |
| +package main |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestPrintBackhaulReport(t *testing.T) { |
| + var b strings.Builder |
| + printBackhaulReport(&b, backhaulReport{ |
| + Period: "mtd", CoveredDays: 3, PeriodDays: 10, EgressGB: 14, BackhaulGB: 5, BackhaulReq1k: 7, |
| + ShareOfEgress: 5.0 / 14, InterRegionGBUSD: 0.02, AbsorbedUSD: 0.10, |
| + Accounts: []backhaulAccount{ |
| + {Account: "bab", Name: "ops", EgressGB: 10, BackhaulGB: 2.5, BackhaulReq1k: 7, ShareOfEgress: 0.25, AbsorbedUSD: 0.225}, |
| + }, |
| + }) |
| + out := b.String() |
| + for _, want := range []string{"backhaul mtd (3/10 days covered): 5.00 GB of 14.00 GB egress (35.7%)", "inter-region leg ≈ $0.10 at $0.020/GB, absorbed", "bab", "ops", "25.0%"} { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("missing %q in:\n%s", want, out) |
| + } |
| + } |
| + var empty strings.Builder |
| + printBackhaulReport(&empty, backhaulReport{Period: "7d", PeriodDays: 7}) |
| + if !strings.Contains(empty.String(), "no account backhauled anything") { |
| + t.Errorf("empty report printed a bare header:\n%s", empty.String()) |
| + } |
| +} |
diff --git a/internal/console/economicsbackhaul_test.go b/internal/console/economicsbackhaul_test.go| new file mode 100644 |
| index 000000000..1f54633ff |
| --- /dev/null |
| +++ b/internal/console/economicsbackhaul_test.go |
| @@ -0,0 +1,116 @@ |
| +package console |
| + |
| +import ( |
| + "encoding/json" |
| + "io" |
| + "math" |
| + "net/http" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/economics" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +// The report is the hop subset priced at the rate card's egress rate and |
| +// stated as absorbed; share is against egress; accounts sort by backhaul |
| +// desc, sqid asc on ties; no egress means no share, never a division. |
| +func TestBackhaulReportMaths(t *testing.T) { |
| + v := &EconomicsView{ |
| + Period: "mtd", CoveredDays: 3, PeriodDays: 10, |
| + Scenario: economics.Scenario{RateCard: economics.RateCard{EgressGBUSD: 0.09, InterRegionGBUSD: 0.02}}, |
| + Names: map[string]string{"bab": "ops"}, |
| + Meters: map[string]map[string]float64{ |
| + "bab": {usage.MeterEgressGB: 10, usage.MeterBackhaulGB: 2.5, usage.MeterBackhaulRequests1k: 7}, |
| + "bae": {usage.MeterEgressGB: 4, usage.MeterBackhaulGB: 2.5}, |
| + "bad": {usage.MeterEgressGB: 0}, |
| + }, |
| + } |
| + rep := buildBackhaulReport(v) |
| + if rep.EgressGB != 14 || rep.BackhaulGB != 5 || rep.BackhaulReq1k != 7 { |
| + t.Fatalf("totals = %+v", rep) |
| + } |
| + if math.Abs(rep.ShareOfEgress-5.0/14) > 1e-9 || math.Abs(rep.AbsorbedUSD-0.10) > 1e-9 || rep.InterRegionGBUSD != 0.02 { |
| + t.Fatalf("share/absorbed = %v/%v", rep.ShareOfEgress, rep.AbsorbedUSD) |
| + } |
| + // bad has egress but no backhaul: counted in the totals, no row. |
| + if len(rep.Accounts) != 2 || rep.Accounts[0].Account != "bab" || rep.Accounts[1].Account != "bae" { |
| + t.Fatalf("order = %+v", rep.Accounts) |
| + } |
| + if rep.Accounts[0].Name != "ops" || math.Abs(rep.Accounts[0].ShareOfEgress-0.25) > 1e-9 || math.Abs(rep.Accounts[1].ShareOfEgress-0.625) > 1e-9 || math.Abs(rep.Accounts[0].AbsorbedUSD-0.05) > 1e-9 { |
| + t.Fatalf("accounts = %+v", rep.Accounts) |
| + } |
| +} |
| + |
| +func getBearer(t *testing.T, u, bearer string) (int, []byte) { |
| + t.Helper() |
| + req, err := http.NewRequest(http.MethodGet, u, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if bearer != "" { |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + body, _ := io.ReadAll(resp.Body) |
| + return resp.StatusCode, body |
| +} |
| + |
| +// Operator only, with the bill door's posture: anyone else sees the same |
| +// bare 404 an unknown route gets. |
| +func TestAPIEconomicsBackhaulGate(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-apibackhaul") |
| + ghostStatus, ghostBody := getBearer(t, fx.ts.URL+"/this-api-route-does-not-exist", token) |
| + // No token: the CLI door's own 401 with its login hint (cliUser), as |
| + // every /api door answers; a token without the operator bit gets the |
| + // ghost 404 — the bit itself must not be probeable. |
| + if status, _ := getBearer(t, fx.ts.URL+"/api/economics/backhaul", ""); status != http.StatusUnauthorized { |
| + t.Fatalf("unauthenticated = %d; want 401", status) |
| + } |
| + if status, body := getBearer(t, fx.ts.URL+"/api/economics/backhaul", token); status != ghostStatus || string(body) != string(ghostBody) { |
| + t.Fatalf("non-operator = %d %q; want the ghost 404", status, body) |
| + } |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, body := getBearer(t, fx.ts.URL+"/api/economics/backhaul?period=mtd", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("operator = %d %q", status, body) |
| + } |
| + var rep backhaulReport |
| + if err := json.Unmarshal(body, &rep); err != nil { |
| + t.Fatalf("unreadable: %v: %s", err, body) |
| + } |
| + if rep.Period != "mtd" || rep.Accounts == nil { |
| + t.Fatalf("report = %+v", rep) |
| + } |
| +} |
| + |
| +// The whole "priced nowhere" claim in one assertion: an account prices |
| +// identically with and without the backhaul meters present. |
| +func TestBackhaulMetersNeverPriceAnAccount(t *testing.T) { |
| + sc := economics.DefaultScenario() |
| + base := map[string]float64{usage.MeterEgressGB: 40, usage.MeterRequests1k: 900, usage.MeterMemGBHours: 800, usage.MeterAppMonths: 3} |
| + with := map[string]float64{} |
| + for k, v := range base { |
| + with[k] = v |
| + } |
| + with[usage.MeterBackhaulGB] = 25 |
| + with[usage.MeterBackhaulRequests1k] = 500 |
| + a := economics.PriceAccount(sc, "bab", base, 12.5) |
| + b := economics.PriceAccount(sc, "bab", with, 12.5) |
| + if a != b { |
| + t.Fatalf("backhaul meters changed an account's price:\n%+v\n%+v", a, b) |
| + } |
| + // And with nothing else metered: the meters must not even count as |
| + // usage (FreeTierPos), the one generic meter walk PriceAccount has. |
| + none := economics.PriceAccount(sc, "bab", map[string]float64{}, 0) |
| + only := economics.PriceAccount(sc, "bab", map[string]float64{usage.MeterBackhaulGB: 25, usage.MeterBackhaulRequests1k: 500}, 0) |
| + if none != only { |
| + t.Fatalf("backhaul-only meters changed an otherwise idle account:\n%+v\n%+v", none, only) |
| + } |
| +} |
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go| index 9e400d3b1..08df9644b 100644 |
| --- a/internal/console/economicspass.go |
| +++ b/internal/console/economicspass.go |
| @@ -10,6 +10,7 @@ import ( |
| "math" |
| "net/http" |
| "os" |
| + "sort" |
| "strconv" |
| "strings" |
| "time" |
| @@ -391,6 +392,15 @@ func (s *Server) sweepUsage(ctx context.Context, from, to time.Time, routes []re |
| mm := touch(acct) |
| mm[usage.MeterEgressGB] += float64(row.BytesOut) / 1e9 |
| mm[usage.MeterRequests1k] += float64(row.Requests) / 1000 |
| + // The hop SUBSET (usage.EdgeRow): traffic served through a |
| + // region edge and backhauled from the home box, so the |
| + // platform paid for its egress twice. Never added to the |
| + // totals above (it is inside them); priced nowhere — the |
| + // #284 ruling is "absorb, keep the data" — and surfaced as |
| + // its own meter so the economics pages and `carlos |
| + // economics backhaul` can show what that costs us (#288). |
| + mm[usage.MeterBackhaulGB] += float64(row.HopBytesOut) / 1e9 |
| + mm[usage.MeterBackhaulRequests1k] += float64(row.HopRequests) / 1000 |
| } |
| } |
| } |
| @@ -1380,3 +1390,97 @@ func (s *Server) handleAPIEconomicsBill(w http.ResponseWriter, r *http.Request) |
| } |
| writeJSON(w, http.StatusOK, bill) |
| } |
| + |
| +// backhaulReport is GET /api/economics/backhaul: what serving through region |
| +// edges costs the platform this period (#288). The backhaul meters are the |
| +// hop SUBSET of egress (usage.EdgeRow): bytes the HOME box sent to a region |
| +// edge, which the edge then sent on to the client. The home box's metered |
| +// bytes are already priced as egress; the extra leg is the inter-region |
| +// transfer, priced here at RateCard.InterRegionGBUSD and stated as |
| +// absorbed: a platform cost, in no account's cost or revenue (#284's |
| +// ruling). Accounts with no backhaul are omitted. Operator only, the bill |
| +// door's bare-404 posture for a token without the bit; the route itself is |
| +// discoverable (an unauthenticated GET gets cliUser's 401), which is the |
| +// same as every other /api door. |
| +type backhaulReport struct { |
| + Period string `json:"period"` |
| + CoveredDays int `json:"covered_days"` |
| + PeriodDays int `json:"period_days"` |
| + EgressGB float64 `json:"egress_gb"` |
| + BackhaulGB float64 `json:"backhaul_gb"` |
| + BackhaulReq1k float64 `json:"backhaul_requests_1k"` |
| + ShareOfEgress float64 `json:"share_of_egress"` // 0..1; 0 when no egress |
| + InterRegionGBUSD float64 `json:"inter_region_gb_usd"` // the rate the extra leg is priced at |
| + AbsorbedUSD float64 `json:"absorbed_usd"` |
| + Accounts []backhaulAccount `json:"accounts"` // backhaul desc, account asc on ties |
| +} |
| + |
| +type backhaulAccount struct { |
| + Account string `json:"account"` |
| + Name string `json:"name,omitempty"` |
| + EgressGB float64 `json:"egress_gb"` |
| + BackhaulGB float64 `json:"backhaul_gb"` |
| + BackhaulReq1k float64 `json:"backhaul_requests_1k"` |
| + ShareOfEgress float64 `json:"share_of_egress"` |
| + AbsorbedUSD float64 `json:"absorbed_usd"` |
| +} |
| + |
| +func buildBackhaulReport(v *EconomicsView) backhaulReport { |
| + rep := backhaulReport{Period: v.Period, CoveredDays: v.CoveredDays, PeriodDays: v.PeriodDays, |
| + InterRegionGBUSD: v.Scenario.RateCard.InterRegionGBUSD, Accounts: []backhaulAccount{}} |
| + for acct, meters := range v.Meters { |
| + a := backhaulAccount{Account: acct, Name: v.Names[acct], |
| + EgressGB: meters[usage.MeterEgressGB], BackhaulGB: meters[usage.MeterBackhaulGB], |
| + BackhaulReq1k: meters[usage.MeterBackhaulRequests1k]} |
| + if a.EgressGB > 0 { |
| + a.ShareOfEgress = a.BackhaulGB / a.EgressGB |
| + } |
| + a.AbsorbedUSD = a.BackhaulGB * rep.InterRegionGBUSD |
| + rep.EgressGB += a.EgressGB |
| + rep.BackhaulGB += a.BackhaulGB |
| + rep.BackhaulReq1k += a.BackhaulReq1k |
| + if a.BackhaulGB == 0 && a.BackhaulReq1k == 0 { |
| + continue // egress counted, row omitted: no backhaul, nothing to say |
| + } |
| + rep.Accounts = append(rep.Accounts, a) |
| + } |
| + if rep.EgressGB > 0 { |
| + rep.ShareOfEgress = rep.BackhaulGB / rep.EgressGB |
| + } |
| + rep.AbsorbedUSD = rep.BackhaulGB * rep.InterRegionGBUSD |
| + sort.Slice(rep.Accounts, func(i, j int) bool { |
| + if rep.Accounts[i].BackhaulGB != rep.Accounts[j].BackhaulGB { |
| + return rep.Accounts[i].BackhaulGB > rep.Accounts[j].BackhaulGB |
| + } |
| + return rep.Accounts[i].Account < rep.Accounts[j].Account |
| + }) |
| + return rep |
| +} |
| + |
| +func (s *Server) handleAPIEconomicsBackhaul(w http.ResponseWriter, r *http.Request) { |
| + if r.Method != http.MethodGet { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + addr, ok := s.cliUser(w, r) |
| + if !ok { |
| + return |
| + } |
| + isOp, err := s.store.IsOperator(addr) |
| + if err != nil { |
| + log.Printf("console: api economics backhaul: is-operator: %v", err) |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !isOp { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + v, err := s.LoadEconomics(r.Context(), r.URL.Query().Get("period")) |
| + if err != nil { |
| + log.Printf("console: api economics backhaul: %v", err) |
| + jsonError(w, http.StatusServiceUnavailable, "economics unavailable") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, buildBackhaulReport(v)) |
| +} |
diff --git a/internal/console/economicspass_test.go b/internal/console/economicspass_test.go| index 5ceaf12fa..214690748 100644 |
| --- a/internal/console/economicspass_test.go |
| +++ b/internal/console/economicspass_test.go |
| @@ -317,7 +317,7 @@ func TestStatementsPassGolden(t *testing.T) { |
| putStoreJSON(t, fx.rel, usage.EdgeDayKey("box1", "2026-08-10"), usage.EdgeDay{ |
| V: 1, Box: "box1", Date: "2026-08-10", |
| Hours: map[string]map[string]usage.EdgeRow{"09": { |
| - "a.example.com": {Account: fixtureAcct, App: fixtureApp, Requests: 4000, BytesOut: 2e9}, |
| + "a.example.com": {Account: fixtureAcct, App: fixtureApp, Requests: 4000, BytesOut: 2e9, HopRequests: 1000, HopBytesOut: 5e8}, |
| }}, |
| }) |
| @@ -344,6 +344,10 @@ func TestStatementsPassGolden(t *testing.T) { |
| approxF("instance_hours", st.Meters[usage.MeterInstanceHours], 1.0) |
| approxF("egress", st.Meters[usage.MeterEgressGB], 2.0) |
| approxF("requests", st.Meters[usage.MeterRequests1k], 4.0) |
| + // The hop subset rides along as its own meters (#288) — inside egress |
| + // (the 2.0 GB above already includes the 0.5 GB), never on top of it. |
| + approxF("backhaul", st.Meters[usage.MeterBackhaulGB], 0.5) |
| + approxF("backhaul requests", st.Meters[usage.MeterBackhaulRequests1k], 1.0) |
| if st.DaysInMonth != 31 || st.CoveredDays < 1 { |
| t.Fatalf("coverage = %d/%d", st.CoveredDays, st.DaysInMonth) |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index 660aef4c2..fe3a9bcbf 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -248,6 +248,12 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{ |
| }, |
| "sub": func(a, b float64) float64 { return a - b }, |
| "mul": func(a, b float64) float64 { return a * b }, |
| + "div": func(a, b float64) float64 { |
| + if b == 0 { |
| + return 0 |
| + } |
| + return a / b |
| + }, |
| // project is economics.Project (compute.go), exposed so the headline |
| // tiles' month-to-date figures can carry a "projected for the month" |
| // second line — the spec's "month-to-date with projection" promise. Only |
| @@ -789,6 +795,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("/fleet/economics/scenarios", s.handleEconomicsScenarioSave) |
| s.mux.HandleFunc("/fleet/economics/bill", s.handleEconomicsBillSave) |
| s.mux.HandleFunc("/api/economics/bill", s.handleAPIEconomicsBill) |
| + s.mux.HandleFunc("/api/economics/backhaul", s.handleAPIEconomicsBackhaul) |
| // Account tier (economics-v2, Task 4): same bare-registration/method- |
| // first reasoning as the two doors above. |
| s.mux.HandleFunc("/fleet/economics/tier", s.handleEconomicsTierSave) |
diff --git a/internal/console/templates/economics.html b/internal/console/templates/economics.html| index f599e2a08..ff3107a94 100644 |
| --- a/internal/console/templates/economics.html |
| +++ b/internal/console/templates/economics.html |
| @@ -164,8 +164,10 @@ |
| {{$s3storage := mul (sumMeter .Economics.Meters "storage_gb_month") .Economics.Scenario.RateCard.StorageGBMonthUSD}} |
| {{$s3requests := mul (sumMeter .Economics.Meters "requests_1k") .Economics.Scenario.RateCard.RequestsPer1kUSD}} |
| {{$egress := mul (sumMeter .Economics.Meters "egress_gb") .Economics.Scenario.RateCard.EgressGBUSD}} |
| +{{$backhaulGB := sumMeter .Economics.Meters "backhaul_gb"}} |
| +{{$backhaul := mul $backhaulGB .Economics.Scenario.RateCard.InterRegionGBUSD}} |
| {{$overheads := overheadUSD .Economics.Scenario .Economics.PeriodDays}} |
| -{{$modelTotal := add $ec2 $ebs $s3storage $s3requests $egress $overheads}} |
| +{{$modelTotal := add $ec2 $ebs $s3storage $s3requests $egress $backhaul $overheads}} |
| <h2>Model vs. bill</h2> |
| <div class="tablewrap"> |
| <table> |
| @@ -175,6 +177,7 @@ |
| <tr><td>S3 storage</td><td id="model-s3-storage">{{usd $s3storage}}</td></tr> |
| <tr><td>S3 requests</td><td id="model-s3-requests">{{usd $s3requests}}</td></tr> |
| <tr><td>Egress</td><td id="model-egress">{{usd $egress}}</td></tr> |
| + <tr><td>Backhaul (inter-region leg) <span class="small">{{printf "%.2f" $backhaulGB}} GB{{with sumMeter .Economics.Meters "egress_gb"}}{{if gt . 0.0}} · {{printf "%.1f" (mul (div $backhaulGB .) 100.0)}}% of egress{{end}}{{end}} · absorbed, in no account's bill{{if and (gt $backhaulGB 0.0) (eq .Economics.Scenario.RateCard.InterRegionGBUSD 0.0)}} · <strong>rate unset</strong> — set "Inter-region $/GB" in the knobs{{end}}</span></td><td id="model-backhaul">{{usd $backhaul}}</td></tr> |
| <tr><td>Overheads</td><td id="model-overheads">{{usd $overheads}}</td></tr> |
| <tr><th>Model total</th><th id="model-total">{{usd $modelTotal}}</th></tr> |
| </table> |
| @@ -376,12 +379,14 @@ |
| var s3storage = sumMeter(payload.meters, "storage_gb_month") * sc.rate_card.storage_gb_month_usd; |
| var s3requests = sumMeter(payload.meters, "requests_1k") * sc.rate_card.requests_per_1k_usd; |
| var egress = sumMeter(payload.meters, "egress_gb") * sc.rate_card.egress_gb_usd; |
| - var modelTotal = ec2 + ebs + s3storage + s3requests + egress + overheadTotal; |
| + var backhaul = sumMeter(payload.meters, "backhaul_gb") * (sc.rate_card.inter_region_gb_usd || 0); |
| + var modelTotal = ec2 + ebs + s3storage + s3requests + egress + backhaul + overheadTotal; |
| setText("model-ec2", usd(ec2)); |
| setText("model-ebs", usd(ebs)); |
| setText("model-s3-storage", usd(s3storage)); |
| setText("model-s3-requests", usd(s3requests)); |
| setText("model-egress", usd(egress)); |
| + setText("model-backhaul", usd(backhaul)); |
| setText("model-overheads", usd(overheadTotal)); |
| setText("model-total", usd(modelTotal)); |
| @@ -443,6 +448,7 @@ |
| <div class="knob-row"><label for="knob-storage-gb-month-usd">S3 storage $/GB-month</label><input type="number" step="0.001" min="0" id="knob-storage-gb-month-usd" value="{{.Economics.Scenario.RateCard.StorageGBMonthUSD}}"></div> |
| <div class="knob-row"><label for="knob-requests-per-1k-usd">S3 requests $/1k</label><input type="number" step="0.001" min="0" id="knob-requests-per-1k-usd" value="{{.Economics.Scenario.RateCard.RequestsPer1kUSD}}"></div> |
| <div class="knob-row"><label for="knob-egress-gb-usd">Egress $/GB</label><input type="number" step="0.001" min="0" id="knob-egress-gb-usd" value="{{.Economics.Scenario.RateCard.EgressGBUSD}}"></div> |
| +<div class="knob-row"><label for="knob-inter-region-gb-usd">Inter-region $/GB (backhaul leg)</label><input type="number" step="0.001" min="0" id="knob-inter-region-gb-usd" value="{{.Economics.Scenario.RateCard.InterRegionGBUSD}}"></div> |
| <h3>Overheads</h3> |
| <div class="tablewrap"> |
| @@ -705,7 +711,10 @@ |
| if (revenue > 0) { margin = (revenue - cost) / revenue; } |
| var anyUsage = false; |
| - Object.keys(meters).forEach(function (k) { if (meters[k] > 0) { anyUsage = true; } }); |
| + Object.keys(meters).forEach(function (k) { |
| + if (k === "backhaul_gb" || k === "backhaul_requests_1k") { return; } // data, not usage (#288) |
| + if (meters[k] > 0) { anyUsage = true; } |
| + }); |
| var clipped = bEgress < (meters.egress_gb || 0) || |
| bStorage < (meters.storage_gb_month || 0) || |
| bApps < (meters.app_months || 0) || |
| @@ -810,6 +819,7 @@ |
| sc.rate_card.storage_gb_month_usd = numVal("knob-storage-gb-month-usd"); |
| sc.rate_card.requests_per_1k_usd = numVal("knob-requests-per-1k-usd"); |
| sc.rate_card.egress_gb_usd = numVal("knob-egress-gb-usd"); |
| + sc.rate_card.inter_region_gb_usd = numVal("knob-inter-region-gb-usd"); |
| document.querySelectorAll(".knob-overhead").forEach(function (el) { |
| var i = parseInt(el.getAttribute("data-index"), 10); |
| if (sc.rate_card.overheads && sc.rate_card.overheads[i]) { |
diff --git a/internal/console/templates/economics_accounts.html b/internal/console/templates/economics_accounts.html| index 6db05ef24..ae1d769ad 100644 |
| --- a/internal/console/templates/economics_accounts.html |
| +++ b/internal/console/templates/economics_accounts.html |
| @@ -83,6 +83,7 @@ |
| <th scope="col">CPU-h</th> |
| <th scope="col">GB-h</th> |
| <th scope="col">Egress GB</th> |
| + <th scope="col" title="the part of Egress GB that left its home box for a region edge first — the inter-region leg is a platform cost, absorbed, in no account's bill">Backhaul GB</th> |
| <th scope="col">Storage GB-month</th> |
| <th scope="col">Cost</th> |
| <th scope="col">Would-be revenue</th> |
| @@ -124,6 +125,7 @@ |
| <td>{{printf "%.2f" (index .Meters "cpu_core_hours")}}</td> |
| <td>{{printf "%.2f" (index .Meters "mem_gb_hours")}}</td> |
| <td>{{printf "%.2f" (index .Meters "egress_gb")}}</td> |
| + <td>{{printf "%.2f" (index .Meters "backhaul_gb")}}</td> |
| <td>{{printf "%.2f" (index .Meters "storage_gb_month")}}</td> |
| <td class="js-acct-cost">{{usd .Econ.CostUSD}}</td> |
| <td class="js-acct-revenue">{{usd .Econ.RevenueUSD}}</td> |
diff --git a/internal/economics/compute.go b/internal/economics/compute.go| index cf98f26a0..7ab9a1108 100644 |
| --- a/internal/economics/compute.go |
| +++ b/internal/economics/compute.go |
| @@ -144,7 +144,13 @@ func PriceAccount(sc Scenario, account string, meters map[string]float64, alloca |
| } |
| anyUsage := false |
| - for _, v := range meters { |
| + for k, v := range meters { |
| + // The backhaul meters are data, not usage: a subset of egress, |
| + // priced nowhere (#288). Skipped so that an account cannot look |
| + // "inside the free tier" on their account alone. |
| + if k == usage.MeterBackhaulGB || k == usage.MeterBackhaulRequests1k { |
| + continue |
| + } |
| if v > 0 { |
| anyUsage = true |
| break |
diff --git a/internal/economics/scenario.go b/internal/economics/scenario.go| index 911c0ddd9..ea6dd1743 100644 |
| --- a/internal/economics/scenario.go |
| +++ b/internal/economics/scenario.go |
| @@ -31,7 +31,15 @@ type RateCard struct { |
| StorageGBMonthUSD float64 `json:"storage_gb_month_usd"` |
| RequestsPer1kUSD float64 `json:"requests_per_1k_usd"` |
| EgressGBUSD float64 `json:"egress_gb_usd"` |
| - Overheads []LineItem `json:"overheads"` |
| + // InterRegionGBUSD is what a byte costs to move between the platform's |
| + // own regions — the backhaul leg (#288): a request served through a |
| + // region edge leaves the home box for that edge (this rate) and then |
| + // the edge for the client (EgressGBUSD, which is what the home box's |
| + // metered bytes are priced at). Additive; a scenario saved before it |
| + // existed reads as 0 and the model line shows $0 until the operator |
| + // sets it — never a silent default price for a cost that is real. |
| + InterRegionGBUSD float64 `json:"inter_region_gb_usd,omitempty"` |
| + Overheads []LineItem `json:"overheads"` |
| } |
| // Allocation says how a box's shared cost is split across its tenants. |
| @@ -136,6 +144,7 @@ func DefaultScenario() Scenario { |
| StorageGBMonthUSD: 0.023, |
| RequestsPer1kUSD: 0.005, |
| EgressGBUSD: 0.09, |
| + InterRegionGBUSD: 0.02, |
| Overheads: []LineItem{{Name: "domains + DNS", USDMonth: 5}}, |
| }, |
| Allocation: Allocation{Policy: "blend", CPUWeight: 0.5}, |
diff --git a/internal/usage/usage.go b/internal/usage/usage.go| index 437949e54..dd0ee44a9 100644 |
| --- a/internal/usage/usage.go |
| +++ b/internal/usage/usage.go |
| @@ -23,13 +23,19 @@ const ( |
| MeterInstanceHours = "instance_hours" |
| MeterFleetBoxHoursServer = "fleet_box_hours_server" |
| MeterFleetBoxHoursDevice = "fleet_box_hours_device" |
| - // MeterBackhaulGB is RESERVED, not yet billed: the egress a request |
| - // spent crossing a region on a backhaul hop (EdgeRow.HopBytesOut). The |
| - // ruling that added the counters (2026-08-24) deliberately left the |
| - // pricing to Paul and shipped them dormant — no statement, no rate-card |
| - // line, bills byte-identical to before. The name is reserved here |
| - // because meter names are frozen once merged: reserving beats renaming. |
| + // MeterBackhaulGB is the hop SUBSET of MeterEgressGB (usage.EdgeRow's |
| + // HopBytesOut): bytes that left a home box for a region edge before |
| + // reaching the client. It rides in statements and the economics sweep |
| + // as data (#288) and is priced in NO account's cost, revenue or bill — |
| + // #284's ruling is absorb. A billing engine iterating meters generically |
| + // must skip it, as economics.PriceAccount's usage check does; its priced |
| + // terms name their meters. The platform's own cost for it is the |
| + // inter-region leg, RateCard.InterRegionGBUSD. |
| MeterBackhaulGB = "backhaul_gb" |
| + // MeterBackhaulRequests1k is the request-count twin of MeterBackhaulGB: |
| + // requests that crossed a region before being served. Informational, |
| + // unpriced, same posture. |
| + MeterBackhaulRequests1k = "backhaul_requests_1k" |
| ) |
| // DayOf is the UTC date label used in every usage key. |
diff --git a/main.go b/main.go| index 7d7cd5dbf..2d7419a65 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -207,7 +207,8 @@ Your deployment — stand up and administer boxes, routes and accounts |
| revoke a capability, hibernate it, change its backing |
| instances with no sub-verb: list THIS box's registry routes |
| release-keygen mint the root signing key, or a scoped signing key |
| - economics record the month's AWS bill for the economics dashboard |
| + economics bill: record the month's AWS bill; backhaul: what region |
| + edges cost us this period |
| status -live: what every box last reported; -dry-run: leak check |
| On the host — what systemd runs; rarely typed by hand |