edge: per-route request/egress metering behind EnableUsage
Adds UsageMeter (internal/edge/usagemeter.go): a mutex'd map of usage.EdgeRow keyed by host, Record()'d per request and Drain()'d by the (later) agent flush loop. countingWriter wraps the ResponseWriter to count body bytes without buffering; deliberately omits ReaderFrom so io.Copy falls back to Write. Edge.EnableUsage arms metering atomically. ServeHTTP wraps w in a countingWriter once, right after route lookup succeeds and before the upgrade/cache branching, so all three dispatch shapes (hibernating, cacheable, plain) plus the upgrade path count through one site via a single defer. Kind=="status" routes are excluded (not billable); the tunnel-registration hostname and store door return before route lookup and were already out of scope. Extends TestRoutesHostToTCPUpstream's e2e shape with TestServeHTTPMetersRequestsAndBytesExcludingStatus: 2 GETs to a TCP-backed route show Requests==2 and BytesOut equal to served body bytes, and a Kind=="status" route never appears in Drain().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+247
−0
internal/edge/edge.go+27 −0internal/edge/edge_test.go+70 −0internal/edge/usagemeter.go+84 −0internal/edge/usagemeter_test.go+66 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 219e8697e..3a6cd1bf3 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -101,6 +101,12 @@ type Edge struct { |
| staticPub ed25519.PublicKey |
| staticCacheDir string |
| staticRecheck time.Duration |
| + // usageMeter, when non-nil, is where ServeHTTP records the request/byte |
| + // count of every route it serves (see EnableUsage). Atomic because it is |
| + // armed by the agent role after Edge construction, mirroring how |
| + // tunnelLoop attaches via the ready hook — serving may already be |
| + // underway. |
| + usageMeter atomic.Pointer[UsageMeter] |
| // tunnels, when non-nil, is the reverse-channel hub (tunnel.go): remote |
| // hosts register on its reserved hostname and routes with Tunnel set are |
| // proxied over the channel they name. nil = tunnel serving not configured |
| @@ -198,6 +204,12 @@ func (e *Edge) EnableTunnels(hub *TunnelHub) { |
| e.armStoreDoor() |
| } |
| +// EnableUsage arms per-route request/byte metering. Called by the agent |
| +// role after construction (mirroring how tunnelLoop attaches via the ready |
| +// hook); the bare `carlos edge` role never calls it, so pure-proxy edges |
| +// meter nothing. Safe to call after serving has started (atomic). |
| +func (e *Edge) EnableUsage(m *UsageMeter) { e.usageMeter.Store(m) } |
| + |
| // armStoreDoor builds (or clears) the fleet store door from whatever is |
| // configured now. Called from both EnableStatic and EnableTunnels because the |
| // door needs one thing from each and the two are wired in either order at |
| @@ -548,6 +560,21 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| + // Meter every route this box serves except the platform's own status |
| + // host (not billable) — wrapping w here, once, before the upgrade/cache |
| + // branching below, is what makes all three dispatch shapes (hibernating, |
| + // cacheable, plain) plus the upgrade path count through this single |
| + // site: the defer fires after whichever branch actually served the |
| + // request. Deliberately placed after the tunnel-hostname and store-door |
| + // 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. |
| + 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) }() |
| + } |
| + |
| // A Connection: Upgrade request (a WebSocket handshake, typically GET) |
| // must reach the activator/proxy with the real ResponseWriter, completely |
| // untouched by caching: the stdlib reverse proxy's upgrade path type- |
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go| index 80d00b007..0e2197a38 100644 |
| --- a/internal/edge/edge_test.go |
| +++ b/internal/edge/edge_test.go |
| @@ -1527,3 +1527,73 @@ func TestNoVersionHeaderOnUnversionedRoute(t *testing.T) { |
| t.Fatalf("X-Carlos-Version present (%v), want absent for an unversioned route", got) |
| } |
| } |
| + |
| +// TestServeHTTPMetersRequestsAndBytesExcludingStatus drives a full Edge over |
| +// HTTP (mirroring TestRoutesHostToTCPUpstream) with EnableUsage armed, and |
| +// proves the single wrap site in ServeHTTP counts every plain-path request |
| +// while a Kind=="status" route (the platform's own status page — not |
| +// billable, per the placement rules) never shows up in Drain(). |
| +func TestServeHTTPMetersRequestsAndBytesExcludingStatus(t *testing.T) { |
| + body := "metered response body" |
| + backend, _ := hostEchoBackend(t, body) |
| + addr := backend.Listener.Addr().String() |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "metered.example.com", Kind: "service", Addr: addr, Account: "bab", App: "hello", Status: "ready"}); err != nil { |
| + t.Fatalf("upsert metered route: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "status.example.com", Kind: "status", Status: "ready"}); err != nil { |
| + t.Fatalf("upsert status route: %v", err) |
| + } |
| + |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + // Static/status serving needs a store wired in for the status route to |
| + // answer at all (see testEdgeWithStore); an empty pubkey is fine since |
| + // the status class never verifies signatures. |
| + store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| + _, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatalf("generate key: %v", err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatalf("parse public key: %v", err) |
| + } |
| + e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache")) |
| + store.Put(context.Background(), "status/index.html", []byte("<html>status</html>"), "") |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + |
| + meter := NewUsageMeter() |
| + e.EnableUsage(meter) |
| + |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + for i := 0; i < 2; i++ { |
| + code, got := getHost(t, srv.URL, "metered.example.com") |
| + if code != http.StatusOK || got != body { |
| + t.Fatalf("request %d: %d %q, want 200 %q", i, code, got, body) |
| + } |
| + } |
| + if code, _ := getHost(t, srv.URL, "status.example.com"); code != http.StatusOK { |
| + t.Fatalf("status route: %d, want 200", code) |
| + } |
| + |
| + rows := meter.Drain() |
| + row := rows["metered.example.com"] |
| + wantBytes := int64(2 * len(body)) |
| + if row.Requests != 2 || row.BytesOut != wantBytes { |
| + t.Fatalf("metered.example.com row = %+v, want Requests=2 BytesOut=%d", row, wantBytes) |
| + } |
| + if row.Account != "bab" || row.App != "hello" { |
| + t.Fatalf("metered.example.com row = %+v, want Account=bab App=hello", row) |
| + } |
| + if _, ok := rows["status.example.com"]; ok { |
| + t.Fatalf("status route must not be metered, got %+v", rows["status.example.com"]) |
| + } |
| +} |
diff --git a/internal/edge/usagemeter.go b/internal/edge/usagemeter.go| new file mode 100644 |
| index 000000000..b82caa55a |
| --- /dev/null |
| +++ b/internal/edge/usagemeter.go |
| @@ -0,0 +1,84 @@ |
| +package edge |
| + |
| +import ( |
| + "bufio" |
| + "fmt" |
| + "net" |
| + "net/http" |
| + "sync" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +// UsageMeter accumulates per-route request/byte counters between flushes. |
| +// Record sits on the serving path: one mutex'd map update per request — |
| +// no allocation, no I/O. Drain swaps the map out for the usage flush loop |
| +// (internal/host), so a slow bucket write never blocks serving. |
| +type UsageMeter struct { |
| + mu sync.Mutex |
| + m map[string]usage.EdgeRow |
| +} |
| + |
| +// NewUsageMeter returns an empty meter. |
| +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) { |
| + u.mu.Lock() |
| + row := u.m[rt.Host] |
| + row.Account = release.RouteAccount(rt) |
| + row.App = rt.App |
| + row.Requests += requests |
| + row.BytesOut += bytes |
| + u.m[rt.Host] = row |
| + u.mu.Unlock() |
| +} |
| + |
| +// Drain returns the accumulated rows and resets the meter. |
| +func (u *UsageMeter) Drain() map[string]usage.EdgeRow { |
| + u.mu.Lock() |
| + out := u.m |
| + u.m = map[string]usage.EdgeRow{} |
| + u.mu.Unlock() |
| + return out |
| +} |
| + |
| +// countingWriter counts response body bytes. Deliberately does NOT |
| +// implement io.ReaderFrom: io.Copy falls back to Write, which counts. |
| +// Hijack/Flush forward via the interfaces the wrapped writer provides, |
| +// mirroring recordingResponseWriter (edge.go). |
| +type countingWriter struct { |
| + http.ResponseWriter |
| + n *int64 |
| +} |
| + |
| +func (c *countingWriter) Write(p []byte) (int, error) { |
| + n, err := c.ResponseWriter.Write(p) |
| + *c.n += int64(n) |
| + return n, err |
| +} |
| + |
| +// Hijack forwards to the underlying ResponseWriter's http.Hijacker, when it |
| +// implements one — the same optional-interface pattern the stdlib itself |
| +// uses, and the same forwarding recordingResponseWriter provides. |
| +func (c *countingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { |
| + hj, ok := c.ResponseWriter.(http.Hijacker) |
| + if !ok { |
| + return nil, nil, fmt.Errorf("edge: underlying ResponseWriter does not support http.Hijacker") |
| + } |
| + return hj.Hijack() |
| +} |
| + |
| +// Flush forwards to the underlying ResponseWriter's http.Flusher, when it |
| +// implements one — needed so a metered (non-upgrade) response can still |
| +// stream incrementally to the real client, e.g. Server-Sent Events, instead |
| +// of buffering until the handler returns. |
| +func (c *countingWriter) Flush() { |
| + if f, ok := c.ResponseWriter.(http.Flusher); ok { |
| + f.Flush() |
| + } |
| +} |
diff --git a/internal/edge/usagemeter_test.go b/internal/edge/usagemeter_test.go| new file mode 100644 |
| index 000000000..983174211 |
| --- /dev/null |
| +++ b/internal/edge/usagemeter_test.go |
| @@ -0,0 +1,66 @@ |
| +package edge |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "sync" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +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 |
| + |
| + got := m.Drain() |
| + a := got["a.example.com"] |
| + if a.Requests != 2 || a.BytesOut != 150 || a.Account != "bab" || a.App != "hello" { |
| + t.Fatalf("a row = %+v", a) |
| + } |
| + if got["b.example.com"].Account != release.OpsAccount { |
| + t.Fatalf("b row = %+v", got["b.example.com"]) |
| + } |
| + if again := m.Drain(); len(again) != 0 { |
| + t.Fatalf("drain did not reset: %+v", again) |
| + } |
| +} |
| + |
| +func TestUsageMeterConcurrent(t *testing.T) { |
| + m := NewUsageMeter() |
| + rt := registry.Route{Host: "a.example.com", App: "hello", Account: "bab"} |
| + var wg sync.WaitGroup |
| + for i := 0; i < 50; i++ { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + for j := 0; j < 100; j++ { |
| + m.Record(rt, 1, 10) |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + row := m.Drain()["a.example.com"] |
| + if row.Requests != 5000 || row.BytesOut != 50000 { |
| + t.Fatalf("row = %+v", row) |
| + } |
| +} |
| + |
| +func TestCountingWriterCountsBytes(t *testing.T) { |
| + var n int64 |
| + rec := httptest.NewRecorder() |
| + w := &countingWriter{ResponseWriter: rec, n: &n} |
| + w.WriteHeader(http.StatusTeapot) |
| + w.Write([]byte("hello ")) |
| + w.Write([]byte("world")) |
| + if n != 11 { |
| + t.Fatalf("counted %d bytes", n) |
| + } |
| + if rec.Code != http.StatusTeapot || rec.Body.String() != "hello world" { |
| + t.Fatalf("forwarding broken: %d %q", rec.Code, rec.Body.String()) |
| + } |
| +} |