Let a handler's flush reach the client through the access log (closes #191)
check / check (push) Successful in 3m15s
check / check (push) Successful in 3m15s
The access log's response writer had no Unwrap method, so http.ResponseController stopped at it and a handler's Flush returned http.ErrNotSupported. It now has one. With metrics on, adding Unwrap alone is not enough: go-http-metrics' writer only passes a flush on when the writer inside it has a Flush method, and it sat inside Logging, so the flush silently did nothing. Metrics is now registered outside Logging, and the README's middleware list follows. A new test flushes through the production router, with the defaults and with metrics and Sentry on, on a global route and inside an admin page route group. Model: opus-5-5
This commit is contained in:
@@ -2979,10 +2979,10 @@ Applied to all routes in this order:
|
|||||||
2. **SecurityHeaders** — Production security headers on every response
|
2. **SecurityHeaders** — Production security headers on every response
|
||||||
(HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy,
|
(HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy,
|
||||||
Permissions-Policy)
|
Permissions-Policy)
|
||||||
3. **Logging** — Structured request logging (method, URL, status,
|
3. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
|
||||||
latency, remote IP, user agent, request ID)
|
|
||||||
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
|
|
||||||
`METRICS_PASSWORD` are both set)
|
`METRICS_PASSWORD` are both set)
|
||||||
|
4. **Logging** — Structured request logging (method, URL, status,
|
||||||
|
latency, remote IP, user agent, request ID)
|
||||||
5. **CORS** — Cross-origin resource sharing headers
|
5. **CORS** — Cross-origin resource sharing headers
|
||||||
6. **Timeout** — 60-second request timeout
|
6. **Timeout** — 60-second request timeout
|
||||||
7. **Recoverer** — Panic recovery: one `ERROR` record through
|
7. **Recoverer** — Panic recovery: one `ERROR` record through
|
||||||
@@ -3002,6 +3002,10 @@ local record instead of nothing. What that placement gives up is
|
|||||||
recovery of a panic in the six entries above it, none of which does
|
recovery of a panic in the six entries above it, none of which does
|
||||||
more than set a header or start a timer.
|
more than set a header or start a timer.
|
||||||
|
|
||||||
|
Metrics sits outside Logging so that a handler's flush reaches the
|
||||||
|
client: go-http-metrics' writer passes a flush on only when the writer
|
||||||
|
inside it has a `Flush` method, and the access log's writer has none.
|
||||||
|
|
||||||
Each admin page route group (`/pages`, `/user/*`, `/hooks`,
|
Each admin page route group (`/pages`, `/user/*`, `/hooks`,
|
||||||
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is
|
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is
|
||||||
set, its own **Sentry** error reporting. That Recoverer answers a panic
|
set, its own **Sentry** error reporting. That Recoverer answers a panic
|
||||||
|
|||||||
@@ -233,6 +233,13 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
|||||||
lrw.ResponseWriter.WriteHeader(code)
|
lrw.ResponseWriter.WriteHeader(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unwrap lets http.ResponseController reach the writer underneath, so
|
||||||
|
// a handler can still flush or set a write deadline through the access
|
||||||
|
// log.
|
||||||
|
func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
||||||
|
return lrw.ResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
// concreteLogURL renders the request's own URL for the access log
|
// concreteLogURL renders the request's own URL for the access log
|
||||||
// branches that keep it, with the query string replaced by a fixed
|
// branches that keep it, with the query string replaced by a fixed
|
||||||
// marker.
|
// marker.
|
||||||
|
|||||||
@@ -627,11 +627,9 @@ func TestRecovererIgnoresANonPanickingHandler(t *testing.T) {
|
|||||||
// net/http's own writer from http.ResponseController, so a handler
|
// net/http's own writer from http.ResponseController, so a handler
|
||||||
// that flushes or sets a deadline starts failing.
|
// that flushes or sets a deadline starts failing.
|
||||||
//
|
//
|
||||||
// The recoverer is the only middleware in the chain here. The access
|
// The recoverer is the only middleware in the chain here;
|
||||||
// logger's own wrapper does not implement Unwrap, so a chain
|
// TestFlushThroughProductionRouter in internal/server covers the
|
||||||
// containing it fails this regardless of what the recoverer does;
|
// shipped chain.
|
||||||
// what is being pinned is that the recoverer adds no such opacity of
|
|
||||||
// its own.
|
|
||||||
func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFlushThroughProductionRouter drives http.ResponseController.Flush
|
||||||
|
// through the shipped router and checks that the flush reaches the
|
||||||
|
// writer the server handed in.
|
||||||
|
//
|
||||||
|
// Every middleware that wraps the writer has to pass a flush through,
|
||||||
|
// with an Unwrap method or a Flush of its own. One that does neither
|
||||||
|
// makes Flush return http.ErrNotSupported, or do nothing at all when
|
||||||
|
// the wrapper outside it only looks for a Flush method, and the
|
||||||
|
// handler that trips over it is far from the cause.
|
||||||
|
//
|
||||||
|
// It runs once with the defaults and once with metrics and Sentry on,
|
||||||
|
// because those two add middleware to the chain, and through both the
|
||||||
|
// global middleware and an admin page route group, which adds its own.
|
||||||
|
func TestFlushThroughProductionRouter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cfg func(t *testing.T) *config.Config
|
||||||
|
sentryEnabled bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "defaults",
|
||||||
|
cfg: func(t *testing.T) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
Environment: config.EnvironmentDev,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics and Sentry on",
|
||||||
|
cfg: func(t *testing.T) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return metricsConfig(t, metricsUser, metricsAuthValue)
|
||||||
|
},
|
||||||
|
sentryEnabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnvWithConfig(t, tc.cfg(t))
|
||||||
|
|
||||||
|
var flushErr error
|
||||||
|
|
||||||
|
probe := func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
flushErr = http.NewResponseController(w).Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
routers := map[string]http.Handler{
|
||||||
|
server.ProbePattern: server.NewRouterWithProbeForTest(
|
||||||
|
env.log.Get(), env.cfg, env.mw, env.hnd,
|
||||||
|
tc.sentryEnabled, probe,
|
||||||
|
),
|
||||||
|
server.PageProbePattern: server.NewRouterWithPageProbeForTest(
|
||||||
|
env.log.Get(), env.cfg, env.mw, env.hnd,
|
||||||
|
tc.sentryEnabled, probe,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, router := range routers {
|
||||||
|
flushErr = nil
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, path, nil,
|
||||||
|
))
|
||||||
|
|
||||||
|
require.NoError(t, flushErr, path)
|
||||||
|
assert.True(
|
||||||
|
t, w.Flushed,
|
||||||
|
"%s: the flush must reach the server's writer", path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,16 +61,21 @@ func (s *Server) SetupRoutes() {
|
|||||||
func (s *Server) setupGlobalMiddleware() {
|
func (s *Server) setupGlobalMiddleware() {
|
||||||
s.router.Use(middleware.RequestID)
|
s.router.Use(middleware.RequestID)
|
||||||
s.router.Use(s.mw.SecurityHeaders())
|
s.router.Use(s.mw.SecurityHeaders())
|
||||||
s.router.Use(s.mw.Logging())
|
|
||||||
|
|
||||||
// Metrics recording middleware, registered only when the
|
// Metrics recording middleware, registered only when the
|
||||||
// endpoint that exposes what it records is served. The
|
// endpoint that exposes what it records is served. The
|
||||||
// condition is the same MetricsAuthEnabled the /metrics mount
|
// condition is the same MetricsAuthEnabled the /metrics mount
|
||||||
// in setupRoutes reads.
|
// in setupRoutes reads.
|
||||||
|
//
|
||||||
|
// It goes outside Logging. go-http-metrics' writer passes a flush
|
||||||
|
// on only when the writer inside it has a Flush method, and the
|
||||||
|
// access log's writer has none, so inside Logging a handler's
|
||||||
|
// flush would silently do nothing.
|
||||||
if s.params.Config.MetricsAuthEnabled() {
|
if s.params.Config.MetricsAuthEnabled() {
|
||||||
s.router.Use(s.mw.Metrics())
|
s.router.Use(s.mw.Metrics())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.router.Use(s.mw.Logging())
|
||||||
s.router.Use(s.mw.CORS())
|
s.router.Use(s.mw.CORS())
|
||||||
s.router.Use(middleware.Timeout(requestTimeout))
|
s.router.Use(middleware.Timeout(requestTimeout))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user