carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 09e6fe6b3f25d0ebf05f4cf8bdce90e0a28a5947 parent 5e9b918
9 files changed, +340 −3
  • economics.go +105 −2
  • economics_backhaul_test.go +27 −0
  • internal/console/economicsbackhaul_test.go +90 −0
  • internal/console/economicspass.go +97 −0
  • internal/console/economicspass_test.go +5 −1
  • internal/console/server.go +7 −0
  • internal/console/templates/economics.html +3 −0
  • internal/console/templates/economics_accounts.html +2 −0
  • internal/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>&nbsp;&nbsp;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.