mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
8a1aaf61a8
* fix(server): a failed flush reaches the handler through chi's wrappers (BUG-3827) chi's WrapResponseWriter (logger, metrics, MCP audit) and its compressor implement Flush but not FlushError, so http.ResponseController.Flush called Flush and returned nil. A flush to a client that had stopped reading "succeeded", and sseStream's Dead/Err never saw it; slots were released only because the request context ended. flushErrorWriter wraps each of the four chi writers. Its FlushError runs chi's own Flush (status bookkeeping, the compressor's encoder) and then flushes the writer beneath through a ResponseController, which returns net/http's sticky write error. It unwraps to the chi writer, so write deadlines still reach the connection. Pins: a stalled flush through the outer chain (identity and gzip), the activity stream seeing its failed keepalive flush (test-only seam Server.sseStreamFailedForTesting), and MCPAuditLog with its audit writer started (it is a passthrough without one). With FlushError renamed away, all four fail. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(server): the flush-error wrapper keeps exactly the chi writer's interfaces (BUG-3827 codex r1) The r0 wrapper hid io.ReaderFrom (io.Copy's sendfile path) and http.Pusher, and added Hijacker and Flusher where chi had none. withFlushError now leaves a non-Flusher alone and returns a variant with exactly the chi writer's Hijacker / ReaderFrom / Pusher set; a shape chi never produces is left unwrapped. TestBUG3827_WrapperKeepsTheChiWritersInterfaces pins all seven NewWrapResponseWriter shapes plus the compressor; against the r0 wrapper 7 of 8 fail. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(server): each chain leg proves its encoder, and deflate gets a leg (BUG-3827 codex r2) openStalledStream returns the response head; every leg of the stalled-flush chain test asserts its Content-Encoding, and a deflate leg joins identity and gzip. With compression bypassed, gzip and deflate fail on that assertion. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
56 lines
2.0 KiB
Go
56 lines
2.0 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/metrics"
|
|
)
|
|
|
|
// MetricsMiddleware returns a chi middleware that records Prometheus metrics
|
|
// for every HTTP request: count, duration, and response size.
|
|
//
|
|
// Path labels use chi's RoutePattern (e.g. "/api/v1/workspaces/{slug}/items/{itemSlug}")
|
|
// instead of actual URL paths, keeping label cardinality bounded.
|
|
func MetricsMiddleware(m *metrics.Metrics) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
ww := chimiddleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
|
|
|
next.ServeHTTP(withFlushError(ww), r) // BUG-3827: chi drops flush errors
|
|
|
|
duration := time.Since(start).Seconds()
|
|
status := strconv.Itoa(ww.Status())
|
|
route := chi.RouteContext(r.Context()).RoutePattern()
|
|
if route == "" {
|
|
route = "unmatched"
|
|
}
|
|
method := metricsMethodLabel(r.Method)
|
|
|
|
m.HTTPRequestsTotal.WithLabelValues(method, route, status).Inc()
|
|
m.HTTPRequestDuration.WithLabelValues(method, route, status).Observe(duration)
|
|
m.HTTPResponseSize.WithLabelValues(method, route, status).Observe(float64(ww.BytesWritten()))
|
|
})
|
|
}
|
|
}
|
|
|
|
// metricsMethodLabel bounds the method label (BUG-2817). The middleware runs
|
|
// ahead of authentication and Go's server accepts any token as a method, so
|
|
// recording r.Method verbatim let an anonymous caller mint a new series in
|
|
// three metrics per invented method. Anything outside the standard set is
|
|
// recorded as "OTHER"; the access log still carries the method as sent.
|
|
func metricsMethodLabel(method string) string {
|
|
switch method {
|
|
case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut,
|
|
http.MethodPatch, http.MethodDelete, http.MethodOptions,
|
|
http.MethodConnect, http.MethodTrace:
|
|
return method
|
|
}
|
|
return "OTHER"
|
|
}
|