carlos / platform Public

Merge pull request #307 from carlosframework/hop-reader

economics: what backhaul costs us — meters, pages, API and CLI (#288)
Paul Campbell pushed by paul@keymail.dev 845a850c6a458bb8270dd1ca7526622d94064f50 parent 5e9b918parent cab3b09
14 files changed, +433 −15
  • docs/site/cli.md +9 −0
  • docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md +11 −0
  • economics.go +107 −2
  • economics_backhaul_test.go +28 −0
  • internal/console/economicsbackhaul_test.go +116 −0
  • internal/console/economicspass.go +104 −0
  • internal/console/economicspass_test.go +5 −1
  • internal/console/server.go +7 −0
  • internal/console/templates/economics.html +13 −3
  • internal/console/templates/economics_accounts.html +2 −0
  • internal/economics/compute.go +7 −1
  • internal/economics/scenario.go +10 −1
  • internal/usage/usage.go +12 −6
  • main.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