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. 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() } // 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 and OBSERVES the final status // and Content-Type (spec 2026-09-07 §3.3): it is the one wrapper installed // at a single site for every response shape — cache hit, plain proxy, // hibernating, upgrade — so it is where the edge learns that an upstream // answered 5xx (an edge error) or served HTML (a pageview, step 2). It // sits below the compressor, so Content-Type is the synced value // gzipWriter.syncHeader copied down before calling this WriteHeader. // 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 status int // final status (>= 200); 0 until one is written ctype string // Content-Type at the final WriteHeader wroteHdr bool } // WriteHeader latches the first final status. 1xx are forwarded and never // latched (recordingResponseWriter.WriteHeader's reasoning). func (c *countingWriter) WriteHeader(status int) { if status < 200 { c.ResponseWriter.WriteHeader(status) return } if !c.wroteHdr { c.wroteHdr = true c.status = status c.ctype = c.ResponseWriter.Header().Get("Content-Type") } c.ResponseWriter.WriteHeader(status) } func (c *countingWriter) Write(p []byte) (int, error) { if !c.wroteHdr { c.wroteHdr = true c.status = http.StatusOK c.ctype = c.ResponseWriter.Header().Get("Content-Type") } 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() } }