edge: the counting writer observes status; upstream 5xx become edge errors
Installed unconditionally at the one site every response shape passes; the activator marks its own wake-path 503s so they are filed once, as the platform log error they already are (spec 2026-09-07 §3.2–3.3). The answer flag lives in internal/edge, not internal/activator as the plan had it: the activator already imports the edge (backend_exec.go, for edge.Cache and edge.ListenControl), so the flag there would have been an import cycle. The activator calls edge.MarkAnswered; the stale doc comment claiming the dependency points edge→activator is corrected to match the code.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
8 files changed,
+327
−20
internal/activator/activator.go+14 −2internal/edge/answered.go+37 −0internal/edge/answered_test.go+19 −0internal/edge/edge.go+77 −13internal/edge/edgeerrors_test.go+113 −0internal/edge/usagemeter.go+34 −5internal/edge/usagemeter_test.go+26 −0internal/host/agent.go+7 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index f1b545544..dd5fbbf24 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -54,6 +54,7 @@ import ( |
| "sync" |
| "time" |
| + "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -371,8 +372,10 @@ func (e *errLeaseHeld) Error() string { |
| var errWakeTimeout = errors.New("timed out waiting for instance wake") |
| // Service is the activator (Eleven's activatorSvc). It structurally satisfies |
| -// the edge package's Activator interface — internal/edge is deliberately not |
| -// imported: the dependency points edge→activator only. |
| +// the edge package's Activator interface — the edge never imports this |
| +// package: the dependency points activator→edge only (backend_exec.go takes |
| +// edge.Cache and edge.ListenControl; serve calls edge.MarkAnswered), and the |
| +// seam back is the interface the edge declares for itself. |
| type Service struct { |
| cfg Config |
| backend Backend |
| @@ -1297,6 +1300,9 @@ func (a *Service) serve(w http.ResponseWriter, r *http.Request, rt registry.Rout |
| return true // the sidecar hung up while queued on a wake |
| } |
| w.Header().Set("Retry-After", "5") |
| + // Ours, not the app's: the edge must not file this 503 as an |
| + // edge error (edge/answered.go; spec 2026-09-07 §3.2). |
| + edge.MarkAnswered(r) |
| http.Error(w, "instance is asleep; sidecar requests do not wake it", http.StatusServiceUnavailable) |
| return true |
| } |
| @@ -1317,14 +1323,20 @@ func (a *Service) serve(w http.ResponseWriter, r *http.Request, rt registry.Rout |
| case errors.As(err, &held): |
| retry := int(held.retryAfter/time.Second) + 1 |
| w.Header().Set("Retry-After", strconv.Itoa(retry)) |
| + edge.MarkAnswered(r) |
| http.Error(w, "instance is waking elsewhere; retry shortly", http.StatusServiceUnavailable) |
| case errors.Is(err, errWakeTimeout): |
| w.Header().Set("Retry-After", "5") |
| + edge.MarkAnswered(r) |
| http.Error(w, "instance is waking; retry shortly", http.StatusServiceUnavailable) |
| case errors.Is(err, context.Canceled): |
| // The client hung up while queued; nothing to write. |
| default: |
| + // The wake failure is already the member's platform log error |
| + // (this log line, shipped by logsink.go); marking it keeps the |
| + // edge from filing the same failure a second time. |
| log.Printf("activator: wake %s: %v", rt.Host, err) |
| + edge.MarkAnswered(r) |
| http.Error(w, "instance failed to wake", http.StatusServiceUnavailable) |
| } |
| return true |
diff --git a/internal/edge/answered.go b/internal/edge/answered.go| new file mode 100644 |
| index 000000000..c74fbd578 |
| --- /dev/null |
| +++ b/internal/edge/answered.go |
| @@ -0,0 +1,37 @@ |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| +) |
| + |
| +// answeredKey carries the flag the edge attaches so it can tell a 503 the |
| +// activator wrote on the wake path from one the upstream returned. The |
| +// wake failure already reaches the member as a platform log error |
| +// (emitEvent, logsink.go); the edge must not also file it as an edge |
| +// error (spec 2026-09-07 §3.2, "exactly once"). |
| +// |
| +// The flag lives HERE, in the edge, rather than in the activator, because |
| +// the import direction between the two is already fixed the other way: |
| +// internal/activator imports internal/edge (backend_exec.go, for |
| +// edge.Cache and edge.ListenControl) while the edge reaches the activator |
| +// only through the Activator interface (activator.go's doc: "internal/edge |
| +// is deliberately not" imported). So the activator calls edge.MarkAnswered |
| +// before each of its four wake-path 503s; putting these two functions in |
| +// the activator instead would be an import cycle. |
| +type answeredKey struct{} |
| + |
| +// WithAnswerFlag returns r carrying a fresh flag and the flag itself. |
| +func WithAnswerFlag(r *http.Request) (*http.Request, *bool) { |
| + f := new(bool) |
| + return r.WithContext(context.WithValue(r.Context(), answeredKey{}, f)), f |
| +} |
| + |
| +// MarkAnswered sets the flag when r carries one; a no-op otherwise — so the |
| +// activator can call it unconditionally, including on the paths (a |
| +// non-hibernating route, a direct unit test) where the edge attached none. |
| +func MarkAnswered(r *http.Request) { |
| + if f, ok := r.Context().Value(answeredKey{}).(*bool); ok { |
| + *f = true |
| + } |
| +} |
diff --git a/internal/edge/answered_test.go b/internal/edge/answered_test.go| new file mode 100644 |
| index 000000000..8c60a67e5 |
| --- /dev/null |
| +++ b/internal/edge/answered_test.go |
| @@ -0,0 +1,19 @@ |
| +package edge |
| + |
| +import ( |
| + "net/http/httptest" |
| + "testing" |
| +) |
| + |
| +func TestAnswerFlagRoundTrip(t *testing.T) { |
| + r := httptest.NewRequest("GET", "http://h/", nil) |
| + MarkAnswered(r) // no flag: a no-op, never a panic |
| + r, flag := WithAnswerFlag(r) |
| + if *flag { |
| + t.Fatal("flag must start false") |
| + } |
| + MarkAnswered(r) |
| + if !*flag { |
| + t.Fatal("MarkAnswered must set the flag the edge holds") |
| + } |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index a58f17548..1a372d47a 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -48,6 +48,7 @@ import ( |
| "golang.org/x/crypto/acme/autocert" |
| + "github.com/carlosframework/platform/internal/apperr" |
| "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -121,6 +122,9 @@ type Edge struct { |
| // tunnelLoop attaches via the ready hook — serving may already be |
| // underway. |
| usageMeter atomic.Pointer[UsageMeter] |
| + // errSink, when set, receives edge-observed errors (an upstream 5xx) |
| + // — spec 2026-09-07 §3.2. Nil disables emission; nothing else changes. |
| + errSink atomic.Pointer[errorSinkRef] |
| // 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 |
| @@ -321,6 +325,44 @@ func (e *Edge) EnableTunnels(hub *TunnelHub) { |
| // meter nothing. Safe to call after serving has started (atomic). |
| func (e *Edge) EnableUsage(m *UsageMeter) { e.usageMeter.Store(m) } |
| +// ErrorSink is where the edge files an error it observed on the serving |
| +// path. apperr.Sink satisfies it; the interface lives here so this package |
| +// depends on the contract, not the buffer. |
| +type ErrorSink interface { |
| + Add(acct, app string, r apperr.Record) |
| +} |
| + |
| +type errorSinkRef struct{ ErrorSink } |
| + |
| +// EnableErrors wires the error sink in. Called once at startup by the |
| +// agent; nil is a valid no-op. |
| +func (e *Edge) EnableErrors(s ErrorSink) { |
| + if s == nil { |
| + e.errSink.Store(nil) |
| + return |
| + } |
| + e.errSink.Store(&errorSinkRef{s}) |
| +} |
| + |
| +// emitEdgeError files one edge-observed 5xx for rt. The path is sanitised |
| +// (query and token segments off) before it becomes the record's name. |
| +func (e *Edge) emitEdgeError(rt registry.Route, r *http.Request, status int) { |
| + ref := e.errSink.Load() |
| + if ref == nil { |
| + return |
| + } |
| + path := apperr.SanitizePath(r.URL.Path) |
| + ref.Add(release.RouteAccount(rt), rt.App, apperr.Record{ |
| + Host: rt.Host, |
| + Source: apperr.SourceEdge, |
| + Kind: apperr.KindHTTP, |
| + Name: fmt.Sprintf("%d %s %s", status, r.Method, path), |
| + Path: path, |
| + Status: status, |
| + Release: rt.Version, |
| + }) |
| +} |
| + |
| // 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 |
| @@ -1218,15 +1260,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. |
| + // Observe every route this box serves except the platform's own status |
| + // host (not billable, and not a member's app) — wrapping w here, once, |
| + // before the upgrade/cache branching below, is what makes all three |
| + // dispatch shapes (hibernating, cacheable, plain) plus the upgrade path |
| + // pass 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. |
| + // |
| + // The observing writer is installed UNCONDITIONALLY now (spec |
| + // 2026-09-07 §3.3) — the usage meter is one of its two consumers, the |
| + // errors sink the other — because a box that meters nothing still has |
| + // errors to file. Meter and sink are each nil-safe, so a pure-proxy |
| + // `carlos edge` pays one wrapper and no more. |
| // |
| // hopped is the ATTRIBUTION half of the backhaul egress ruling |
| // (2026-08-24): a request that arrived over a verified hop is metered |
| @@ -1238,10 +1286,26 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| // 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, hopped) }() |
| + var bytes int64 |
| + ob := &countingWriter{ResponseWriter: w, n: &bytes} |
| + w = ob |
| + var answered *bool |
| + if up.hibernating { |
| + // The activator marks the wake-path 503s it writes itself |
| + // (MarkAnswered, answered.go); those are already the member's |
| + // platform log error and are not filed again as edge errors |
| + // (spec §3.2, "exactly once"). |
| + r, answered = WithAnswerFlag(r) |
| + } |
| + if up.route.Kind != "status" { |
| + defer func() { |
| + if m := e.usageMeter.Load(); m != nil { |
| + m.Record(up.route, 1, bytes, hopped) |
| + } |
| + if ob.status >= 500 && (answered == nil || !*answered) { |
| + e.emitEdgeError(up.route, r, ob.status) |
| + } |
| + }() |
| } |
| // A Connection: Upgrade request (a WebSocket handshake, typically GET) |
diff --git a/internal/edge/edgeerrors_test.go b/internal/edge/edgeerrors_test.go| new file mode 100644 |
| index 000000000..f6c20bf5f |
| --- /dev/null |
| +++ b/internal/edge/edgeerrors_test.go |
| @@ -0,0 +1,113 @@ |
| +package edge |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "sync" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/apperr" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +type memErrSink struct { |
| + mu sync.Mutex |
| + recs []apperr.Record |
| + apps []string |
| +} |
| + |
| +func (m *memErrSink) Add(acct, app string, r apperr.Record) { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + m.recs = append(m.recs, r) |
| + m.apps = append(m.apps, acct+"/"+app) |
| +} |
| + |
| +func TestEdgeEmitsErrorOnUpstream5xx(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + switch r.URL.Path { |
| + case "/boom": |
| + http.Error(w, "kaboom", http.StatusInternalServerError) |
| + case "/missing": |
| + http.NotFound(w, r) |
| + default: |
| + w.Header().Set("Content-Type", "text/html") |
| + w.Write([]byte("<p>ok</p>")) |
| + } |
| + })) |
| + t.Cleanup(backend.Close) |
| + reg := openReg(t) |
| + rt := registry.Route{Host: "app.example.com", Kind: "service", Addr: backend.Listener.Addr().String(), Status: "ready", App: "web", Version: "v7"} |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sink := &memErrSink{} |
| + e.EnableErrors(sink) |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + do := func(method, path string) int { |
| + req, _ := http.NewRequest(method, srv.URL+path, nil) |
| + req.Host = "app.example.com" |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + return resp.StatusCode |
| + } |
| + if do("POST", "/boom?token=abc") != 500 || do("GET", "/missing") != 404 || do("GET", "/") != 200 { |
| + t.Fatal("upstream statuses must pass through unchanged") |
| + } |
| + if len(sink.recs) != 1 { |
| + t.Fatalf("errors emitted = %d, want 1 (the 500 only)", len(sink.recs)) |
| + } |
| + r := sink.recs[0] |
| + if r.Source != apperr.SourceEdge || r.Kind != apperr.KindHTTP || r.Status != 500 || r.Host != "app.example.com" || r.Release != "v7" { |
| + t.Fatalf("record = %+v", r) |
| + } |
| + if r.Name != "500 POST /boom" || r.Path != "/boom" || strings.Contains(r.Name, "token") { |
| + t.Fatalf("name/path = %q %q", r.Name, r.Path) |
| + } |
| +} |
| + |
| +// answering503 is an Activator that answers every request itself with the |
| +// wake-path 503 the real one writes, and marks it as the activator's own — |
| +// the same MarkAnswered call internal/activator's serve makes before each of |
| +// its four wake-path http.Error(…, 503) writes. |
| +type answering503 struct{} |
| + |
| +func (answering503) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + MarkAnswered(r) |
| + http.Error(w, "instance failed to wake", http.StatusServiceUnavailable) |
| + return true |
| +} |
| +func (answering503) RetireExcept(map[string]bool) {} |
| + |
| +func TestEdgeSkipsActivatorAnswered503(t *testing.T) { |
| + reg := openReg(t) |
| + rt := registry.Route{Host: "sleepy.example.com", Kind: "instance", Socket: t.TempDir() + "/x.sock", Status: "ready", Hibernate: true, App: "web"} |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + e, err := New(reg, answering503{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sink := &memErrSink{} |
| + e.EnableErrors(sink) |
| + rec := httptest.NewRecorder() |
| + req := httptest.NewRequest("GET", "http://sleepy.example.com/", nil) |
| + e.ServeHTTP(rec, req) |
| + if rec.Code != 503 { |
| + t.Fatalf("status = %d", rec.Code) |
| + } |
| + if len(sink.recs) != 0 { |
| + t.Fatalf("an activator-answered 503 is already a platform log error; got %d edge errors", len(sink.recs)) |
| + } |
| +} |
diff --git a/internal/edge/usagemeter.go b/internal/edge/usagemeter.go| index 1cbd7db65..6afb2fa38 100644 |
| --- a/internal/edge/usagemeter.go |
| +++ b/internal/edge/usagemeter.go |
| @@ -57,16 +57,45 @@ func (u *UsageMeter) Drain() map[string]usage.EdgeRow { |
| 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). |
| +// 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 |
| + 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 |
diff --git a/internal/edge/usagemeter_test.go b/internal/edge/usagemeter_test.go| index abe4e46dd..15dbdb69b 100644 |
| --- a/internal/edge/usagemeter_test.go |
| +++ b/internal/edge/usagemeter_test.go |
| @@ -90,3 +90,29 @@ func TestCountingWriterCountsBytes(t *testing.T) { |
| t.Fatalf("forwarding broken: %d %q", rec.Code, rec.Body.String()) |
| } |
| } |
| + |
| +func TestCountingWriterObservesStatusAndType(t *testing.T) { |
| + var n int64 |
| + rec := httptest.NewRecorder() |
| + w := &countingWriter{ResponseWriter: rec, n: &n} |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.WriteHeader(103) // informational: forwarded, not latched |
| + w.WriteHeader(http.StatusBadGateway) |
| + w.WriteHeader(http.StatusOK) // superfluous second final status: ignored |
| + if w.status != http.StatusBadGateway || w.ctype != "text/html; charset=utf-8" { |
| + t.Fatalf("latched %d %q", w.status, w.ctype) |
| + } |
| + // Implicit 200 at first Write. |
| + rec2 := httptest.NewRecorder() |
| + w2 := &countingWriter{ResponseWriter: rec2, n: &n} |
| + w2.Write([]byte("x")) |
| + if w2.status != http.StatusOK { |
| + t.Fatalf("implicit status = %d", w2.status) |
| + } |
| + // Nothing written at all: status stays 0 (the caller treats it as a |
| + // bodiless 200 that is never a pageview and never an error). |
| + w3 := &countingWriter{ResponseWriter: httptest.NewRecorder(), n: &n} |
| + if w3.status != 0 { |
| + t.Fatal("untouched writer must report 0") |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 04e45887d..14d1385a2 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -439,6 +439,13 @@ func RunAgent(args []string) { |
| setUpstreamSource(e.UpstreamStates) |
| m := edge.NewUsageMeter() |
| e.EnableUsage(m) |
| + // Same arming point as the meter: the edge observes an upstream 5xx |
| + // on the serving path and files it here (spec 2026-09-07 §3.2), and |
| + // errshipLoop above ships what this fills. Guarded, because a typed |
| + // nil inside the interface would NOT be nil to the edge. |
| + if errSink != nil { |
| + e.EnableErrors(errSink) |
| + } |
| go usageLoop(m.Drain) |
| }, |
| // Certificate issuance is a deployment-global duty, so it runs where |