economics: what backhaul costs us — meters, pages, API and CLI (#288)
The hop subset usage.EdgeRow has carried since #283 now reaches the operator: the usage sweep folds hop_bytes_out/hop_requests into the reserved backhaul_gb meter and a new backhaul_requests_1k, priced nowhere (#284's ruling: absorb, keep the data). The economics page shows the absorbed figure under Egress (inside it, never added; knob-live), the accounts page gains a Backhaul GB column, GET /api/economics/backhaul returns the per-account report for operators (bill door's 404 posture), and carlos economics backhaul prints it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+340
−3
economics.go+105 −2economics_backhaul_test.go+27 −0internal/console/economicsbackhaul_test.go+90 −0internal/console/economicspass.go+97 −0internal/console/economicspass_test.go+5 −1internal/console/server.go+7 −0internal/console/templates/economics.html+3 −0internal/console/templates/economics_accounts.html+2 −0internal/usage/usage.go+4 −0
diff --git a/economics.go b/economics.go| index 54221e8fa..f611b9d8e 100644 |
| --- a/economics.go |
| +++ b/economics.go |
| @@ -16,17 +16,21 @@ import ( |
| "errors" |
| "flag" |
| "fmt" |
| + "io" |
| "log" |
| "math" |
| "net/http" |
| + "net/url" |
| "os" |
| "sort" |
| "strconv" |
| "strings" |
| + "text/tabwriter" |
| "time" |
| ) |
| 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 +126,110 @@ 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 egress $/GB 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"` |
| + EgressGBUSD float64 `json:"egress_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>.") |
| + } |
| + rep, err := sess.economicsBackhaul(context.Background(), *period) |
| + if err != nil { |
| + log.Fatalf("economics backhaul: %v", err) |
| + } |
| + printBackhaulReport(os.Stdout, rep) |
| +} |
| + |
| +func printBackhaulReport(stdout io.Writer, rep backhaulReport) { |
| + fmt.Fprintf(stdout, "backhaul %s (%d/%d days covered): %.2f GB of %.2f GB egress (%.1f%%), %.1fk requests — ≈ $%.2f at $%.3f/GB, absorbed\n", |
| + rep.Period, rep.CoveredDays, rep.PeriodDays, rep.BackhaulGB, rep.EgressGB, rep.ShareOfEgress*100, rep.BackhaulReq1k, rep.AbsorbedUSD, rep.EgressGBUSD) |
| + w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| + fmt.Fprintln(w, "ACCOUNT\tNAME\tEGRESS GB\tBACKHAUL GB\tSHARE\tREQ (k)\tABSORBED") |
| + for _, a := range rep.Accounts { |
| + if a.BackhaulGB == 0 && a.BackhaulReq1k == 0 { |
| + continue |
| + } |
| + fmt.Fprintf(w, "%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) |
| + } |
| + w.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..29a4d7b6a |
| --- /dev/null |
| +++ b/economics_backhaul_test.go |
| @@ -0,0 +1,27 @@ |
| +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, EgressGBUSD: 0.09, AbsorbedUSD: 0.45, |
| + Accounts: []backhaulAccount{ |
| + {Account: "bab", Name: "ops", EgressGB: 10, BackhaulGB: 2.5, BackhaulReq1k: 7, ShareOfEgress: 0.25, AbsorbedUSD: 0.225}, |
| + {Account: "bad", EgressGB: 0}, |
| + }, |
| + }) |
| + out := b.String() |
| + for _, want := range []string{"backhaul mtd (3/10 days covered): 5.00 GB of 14.00 GB egress (35.7%)", "$0.45 at $0.090/GB, absorbed", "bab", "ops", "25.0%"} { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("missing %q in:\n%s", want, out) |
| + } |
| + } |
| + if strings.Contains(out, "bad") { |
| + t.Errorf("an account with no backhaul was listed:\n%s", out) |
| + } |
| +} |
diff --git a/internal/console/economicsbackhaul_test.go b/internal/console/economicsbackhaul_test.go| new file mode 100644 |
| index 000000000..c32185392 |
| --- /dev/null |
| +++ b/internal/console/economicsbackhaul_test.go |
| @@ -0,0 +1,90 @@ |
| +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}}, |
| + 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.45) > 1e-9 || rep.EgressGBUSD != 0.09 { |
| + t.Fatalf("share/absorbed = %v/%v", rep.ShareOfEgress, rep.AbsorbedUSD) |
| + } |
| + if len(rep.Accounts) != 3 || rep.Accounts[0].Account != "bab" || rep.Accounts[1].Account != "bae" || rep.Accounts[2].Account != "bad" { |
| + 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 || rep.Accounts[2].ShareOfEgress != 0 { |
| + 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) |
| + } |
| +} |
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go| index 9e400d3b1..f8f1cf37c 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,90 @@ 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) — inside the egress totals, never on |
| +// top of them — priced at the rate card's egress $/GB and stated as |
| +// absorbed: not in any account's cost or revenue (#284's ruling). Operator |
| +// only, same bare-404 posture as the bill endpoint. |
| +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 |
| + EgressGBUSD float64 `json:"egress_gb_usd"` // the rate it 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, |
| + EgressGBUSD: v.Scenario.RateCard.EgressGBUSD, 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.EgressGBUSD |
| + rep.EgressGB += a.EgressGB |
| + rep.BackhaulGB += a.BackhaulGB |
| + rep.BackhaulReq1k += a.BackhaulReq1k |
| + rep.Accounts = append(rep.Accounts, a) |
| + } |
| + if rep.EgressGB > 0 { |
| + rep.ShareOfEgress = rep.BackhaulGB / rep.EgressGB |
| + } |
| + rep.AbsorbedUSD = rep.BackhaulGB * rep.EgressGBUSD |
| + 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) |
| + http.Error(w, "economics unavailable", http.StatusServiceUnavailable) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + _ = json.NewEncoder(w).Encode(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..99395623c 100644 |
| --- a/internal/console/templates/economics.html |
| +++ b/internal/console/templates/economics.html |
| @@ -175,6 +175,8 @@ |
| <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> |
| + {{$backhaulGB := sumMeter .Economics.Meters "backhaul_gb"}} |
| + <tr class="muted"><td> of which backhaul (absorbed, inside egress — not added)</td><td><span id="model-backhaul">{{usd (mul $backhaulGB .Economics.Scenario.RateCard.EgressGBUSD)}}</span> · {{printf "%.2f" $backhaulGB}} GB{{with sumMeter .Economics.Meters "egress_gb"}}{{if gt . 0.0}} · {{printf "%.1f" (mul (div $backhaulGB .) 100.0)}}%{{end}}{{end}}</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> |
| @@ -382,6 +384,7 @@ |
| setText("model-s3-storage", usd(s3storage)); |
| setText("model-s3-requests", usd(s3requests)); |
| setText("model-egress", usd(egress)); |
| + setText("model-backhaul", usd(sumMeter(payload.meters, "backhaul_gb") * sc.rate_card.egress_gb_usd)); |
| setText("model-overheads", usd(overheadTotal)); |
| setText("model-total", usd(modelTotal)); |
diff --git a/internal/console/templates/economics_accounts.html b/internal/console/templates/economics_accounts.html| index 6db05ef24..d7888a267 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 served through a region edge and backhauled from the home box — inside Egress GB, absorbed by the platform">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/usage/usage.go b/internal/usage/usage.go| index 437949e54..72f6092f0 100644 |
| --- a/internal/usage/usage.go |
| +++ b/internal/usage/usage.go |
| @@ -30,6 +30,10 @@ const ( |
| // line, bills byte-identical to before. The name is reserved here |
| // because meter names are frozen once merged: reserving beats renaming. |
| 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. |