edge: a hop-served exchange is metered once, and annotated as hopped
The home box already meters every backhauled request exactly once — it holds the route's authoritative row, and the counting writer sits below the compressor, so the count is the wire bytes it sends. What was missing was the tag saying the exchange also crossed a region. Record gains a hopped flag (one method, not a second RecordHop: two methods for one exchange invite calling both), and the meter site passes acceptHop's own answer. Nothing in the wrapper stack moves — recorder above the compressor, counter below it — because the annotation changes WHAT is written at drain, never WHERE bytes are counted. The sending edge stays meterless by design: it backhauls precisely because it has no route row for the host, so it has no account or app to attribute anything to.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+104
−7
internal/edge/backhaul_e2e_test.go+50 −0internal/edge/edge.go+12 −1internal/edge/usagemeter.go+12 −2internal/edge/usagemeter_test.go+30 −4
diff --git a/internal/edge/backhaul_e2e_test.go b/internal/edge/backhaul_e2e_test.go| index 98eca40ba..5716803e0 100644 |
| --- a/internal/edge/backhaul_e2e_test.go |
| +++ b/internal/edge/backhaul_e2e_test.go |
| @@ -760,6 +760,56 @@ func TestBackhaulE2EAcrossTwoEdges(t *testing.T) { |
| }) |
| } |
| +// ---- attribution: the hop annotation, end to end ----------------------------- |
| + |
| +// The attribution half of the egress ruling, on the real two-edge scene: a |
| +// backhauled request is metered ONCE, on the box that holds the route's |
| +// authoritative row, and that one row also carries the hop annotation. The |
| +// sending edge records nothing at all for the host — it has no route row and |
| +// therefore no account or app to bill, which is exactly why the annotation |
| +// lives here and not there. |
| +func TestHopServedRequestMetersOnceWithHopCounters(t *testing.T) { |
| + s := newBackhaulScene(t) |
| + s.converge(t) |
| + |
| + home, sender := NewUsageMeter(), NewUsageMeter() |
| + s.a.edge.EnableUsage(home) |
| + s.b.edge.EnableUsage(sender) |
| + |
| + resp, body := s.get(t, s.b, "https://"+e2eHomedHost+"/deep?q=1", nil, true) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("status = %d, want 200", resp.StatusCode) |
| + } |
| + if seen := e2eParseApp(t, body); seen.marker != e2eHomeMarker { |
| + t.Fatalf("marker = %q, want the home edge's app", seen.marker) |
| + } |
| + |
| + rows := home.Drain() |
| + row, ok := rows[e2eHomedHost] |
| + if !ok { |
| + t.Fatalf("the home edge metered nothing for %s: %+v", e2eHomedHost, rows) |
| + } |
| + if row.Requests != 1 { |
| + t.Fatalf("row = %+v, want exactly one request — a hop is ONE metered exchange, not two", row) |
| + } |
| + if row.BytesOut == 0 { |
| + t.Fatalf("row = %+v, want the wire bytes the home edge sent", row) |
| + } |
| + if row.HopRequests != 1 || row.HopBytesOut != row.BytesOut { |
| + t.Fatalf("row = %+v, want the whole exchange annotated as hopped (hop_bytes_out == bytes_out)", row) |
| + } |
| + if row.HopBytesOut > row.BytesOut || row.HopRequests > row.Requests { |
| + t.Fatalf("row = %+v — the hop counters are a subset of the totals", row) |
| + } |
| + |
| + // The sending edge is meterless for this host BY DESIGN: no route row, no |
| + // identity, so anything it wrote would be unattributed — and a second |
| + // Record for one exchange is the double count this arrangement refuses. |
| + if got, ok := sender.Drain()[e2eHomedHost]; ok { |
| + t.Fatalf("the sending edge metered %+v for a host it has no route for", got) |
| + } |
| +} |
| + |
| // ---- the single-box regression pin ------------------------------------------- |
| // Everything above is dormant on a deployment with one platform box, and this |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 19a565b38..6adae2809 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -871,10 +871,21 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // returns above (edge.go:531-544) — that hostname isn't a registry |
| // route, so up.route isn't defined yet there, and neither one is |
| // billable traffic. |
| + // |
| + // hopped is the ATTRIBUTION half of the backhaul egress ruling |
| + // (2026-08-24): a request that arrived over a verified hop is metered |
| + // here exactly once, as it always was, and additionally annotated into |
| + // the row's subset hop counters — so every backhauled byte is attributed |
| + // to the route, account and app that caused it, on the box that holds |
| + // the authoritative row. The sending edge stays meterless by design: it |
| + // has no route row for the host and so no identity to bill. Nothing |
| + // about the wrapper stack moves for this — recorder above the |
| + // compressor, this counter below it — because the annotation changes |
| + // WHAT is written at drain, never WHERE the bytes are counted. |
| if m := e.usageMeter.Load(); m != nil && up.route.Kind != "status" { |
| var bytes int64 |
| w = &countingWriter{ResponseWriter: w, n: &bytes} |
| - defer func() { m.Record(up.route, 1, bytes) }() |
| + defer func() { m.Record(up.route, 1, bytes, hopped) }() |
| } |
| // A Connection: Upgrade request (a WebSocket handshake, typically GET) |
diff --git a/internal/edge/usagemeter.go b/internal/edge/usagemeter.go| index b82caa55a..1cbd7db65 100644 |
| --- a/internal/edge/usagemeter.go |
| +++ b/internal/edge/usagemeter.go |
| @@ -26,14 +26,24 @@ func NewUsageMeter() *UsageMeter { |
| return &UsageMeter{m: map[string]usage.EdgeRow{}} |
| } |
| -// Record adds one served exchange for rt. |
| -func (u *UsageMeter) Record(rt registry.Route, requests, bytes int64) { |
| +// Record adds one served exchange for rt. hopped says the request arrived |
| +// over a verified backhaul hop from a sibling edge (homemap.go) — the same |
| +// exchange is then ALSO added to the row's hop counters, which are a subset |
| +// annotation inside Requests/BytesOut and never a second exchange (see |
| +// usage.EdgeRow). One method rather than a separate RecordHop: two methods |
| +// for one exchange invite calling both, which is exactly the double count |
| +// this arrangement exists to make impossible. |
| +func (u *UsageMeter) Record(rt registry.Route, requests, bytes int64, hopped bool) { |
| u.mu.Lock() |
| row := u.m[rt.Host] |
| row.Account = release.RouteAccount(rt) |
| row.App = rt.App |
| row.Requests += requests |
| row.BytesOut += bytes |
| + if hopped { |
| + row.HopRequests += requests |
| + row.HopBytesOut += bytes |
| + } |
| u.m[rt.Host] = row |
| u.mu.Unlock() |
| } |
diff --git a/internal/edge/usagemeter_test.go b/internal/edge/usagemeter_test.go| index 983174211..abe4e46dd 100644 |
| --- a/internal/edge/usagemeter_test.go |
| +++ b/internal/edge/usagemeter_test.go |
| @@ -13,9 +13,9 @@ import ( |
| func TestUsageMeterRecordAndDrain(t *testing.T) { |
| m := NewUsageMeter() |
| rt := registry.Route{Host: "a.example.com", App: "hello", Account: "bab"} |
| - m.Record(rt, 1, 100) |
| - m.Record(rt, 1, 50) |
| - m.Record(registry.Route{Host: "b.example.com", App: "site"}, 1, 10) // Account "" -> release.OpsAccount |
| + m.Record(rt, 1, 100, false) |
| + m.Record(rt, 1, 50, false) |
| + m.Record(registry.Route{Host: "b.example.com", App: "site"}, 1, 10, false) // Account "" -> release.OpsAccount |
| got := m.Drain() |
| a := got["a.example.com"] |
| @@ -30,6 +30,32 @@ func TestUsageMeterRecordAndDrain(t *testing.T) { |
| } |
| } |
| +// A hopped exchange is recorded ONCE — into the totals, as any exchange is — |
| +// and additionally annotated into the hop counters, which are therefore a |
| +// subset of the totals and never an addition to them. A consumer that sums |
| +// BytesOut has already counted every backhauled byte; one that added |
| +// HopBytesOut on top would bill the tenant twice for the same wire bytes. |
| +func TestRecordHopAnnotatesSubset(t *testing.T) { |
| + m := NewUsageMeter() |
| + rt := registry.Route{Host: "a.example.com", App: "hello", Account: "bab"} |
| + m.Record(rt, 1, 100, true) |
| + m.Record(rt, 1, 50, false) |
| + |
| + row := m.Drain()["a.example.com"] |
| + if row.Requests != 2 || row.BytesOut != 150 { |
| + t.Fatalf("row = %+v, want both exchanges counted once in the totals", row) |
| + } |
| + if row.HopRequests != 1 || row.HopBytesOut != 100 { |
| + t.Fatalf("row = %+v, want only the hopped exchange annotated", row) |
| + } |
| + if row.HopRequests > row.Requests || row.HopBytesOut > row.BytesOut { |
| + t.Fatalf("row = %+v — the hop counters must stay inside the totals", row) |
| + } |
| + if row.Account != "bab" || row.App != "hello" { |
| + t.Fatalf("row = %+v, want the hop attributed to the route's own account and app", row) |
| + } |
| +} |
| + |
| func TestUsageMeterConcurrent(t *testing.T) { |
| m := NewUsageMeter() |
| rt := registry.Route{Host: "a.example.com", App: "hello", Account: "bab"} |
| @@ -39,7 +65,7 @@ func TestUsageMeterConcurrent(t *testing.T) { |
| go func() { |
| defer wg.Done() |
| for j := 0; j < 100; j++ { |
| - m.Record(rt, 1, 10) |
| + m.Record(rt, 1, 10, false) |
| } |
| }() |
| } |