Middleware
Middleware in Muzak is an ordinary func(http.Handler) http.Handler, so anything written
for the standard library works unchanged.
type Middleware func(next http.Handler) http.Handler
It operates below the typed layer, on the raw net/http types. Everything typed happens
inside dispatch, at the end of the chain.
What is already installed
The middleware below is installed for you, in this order, outermost first. RequestID,
SecurityHeaders, Recovery and AccessLog are in every application unless you turn them
off; the rest are installed only when the option that asks for them is set, and cost nothing
otherwise.
| Middleware | What it does | Installed |
|---|---|---|
RequestID | Assigns an identifier, records it in the request context, echoes it in X-Request-Id | Always |
| Tracing and the request observer | Starts the request's server span and tells the RequestObserver about the request once its response is written. See Observability | When AppOptions.Tracing names a Tracer or AppOptions.Observer is set |
SecurityHeaders | Sets X-Content-Type-Options, X-Frame-Options and a referrer policy, and Strict-Transport-Security on a response over TLS (not for localhost or an address), never overwriting a value already set. No Content-Security-Policy: a page sets its own | Unless DisableSecurityHeaders is set |
Recovery | Catches a panic, logs it with its stack, answers a generic 500 | Always |
| Health probes | Answers the liveness and readiness endpoints and passes every other request on. See Health Checks | When AppOptions.Health.Enabled is set |
Locale | Resolves the request's locale, records it in the request context, echoes it in Content-Language | When I18n names a store |
AccessLog | One line per request with method, path, status, duration, bytes and identifier | Unless DisableAccessLog is set |
| Allowed hosts and the HTTPS redirect | Refuses a request for a host AllowedHosts does not list with 421, and redirects plain HTTP to https. See Allowed Hosts and HTTPS | When AllowedHosts or RedirectHTTPS is set |
Anything you add with App.Use runs inside that chain, so it already has an identifier
available and is already covered by panic recovery. It also runs inside the CORS policy
when one is configured, and inside cross-origin protection when AppOptions.CrossOriginProtection
is set, which sit between the built-in layers and your middleware, in front of the
documentation routes and the router.
RequestID -> tracing -> SecurityHeaders -> Recovery -> health probes -> Locale -> AccessLog
-> AllowedHosts and RedirectHTTPS -> CORS -> CrossOriginProtection -> your middleware
-> /docs and /openapi.json -> routes
The health endpoints are the exception to "every request reaches your middleware". They are
answered just inside Recovery, before the locale, the access log, CORS and anything installed
with App.Use, so a middleware that requires credentials cannot take every replica out of
rotation. A probe writes an access-log line of its own instead.
That order is what makes an authenticating middleware work for a browser. A browser never
sends credentials on a preflight, so a Use middleware that required them refused every one,
and its own 401 or 429 carried no Access-Control-Allow-Origin, which the browser reports
as a network error rather than the status. Now a preflight is answered by the policy and never
reaches your middleware, and a response your middleware writes itself carries the policy's
headers for an allowed origin and none for a denied one.
A Use middleware that relied on seeing an OPTIONS preflight, to log it or to answer CORS
itself, no longer does when AppOptions.CORS names origins. Without a CORS policy nothing
changes.
Locale sits where it does for two reasons, and it is only there at all when
internationalization is configured. It is inside
Recovery because a custom extractor is your code and can panic. It is outside AccessLog
because middleware further out holds the request as it arrived, so the log can only record
a locale that was resolved before it ran.
Use installs in order, so the first one installed is the outermost. The chain is assembled
once, so a call made after the application has been built panics rather than leave a
middleware, possibly one that denies access, silently uninstalled. Call it before Build,
ServeHTTP, Document or a run method.
Compression
app.Use(muzak.Compress(muzak.CompressionOptions{}))
One line. It negotiates gzip or deflate from Accept-Encoding (deflate is the zlib format
RFC 9110 defines, not a bare DEFLATE stream), records Vary on every response either way, and
declines what compressing would not help. See
Compression for the options and the security
note that goes with it.
CORS
CORS is configured rather than installed, and the zero value denies everything.
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
CORS: muzak.CORSOptions{
AllowedOrigins: []string{"https://app.example.com"},
AllowCredentials: true,
MaxAge: 10 * time.Minute,
},
})
No CORS header is emitted until a policy names an origin or supplies an
AllowOriginFunc, and a wildcard origin combined with credentials is refused as a build
error rather than served. See CORS.
Instrumenting a service
Middleware is the seam for tracing and metrics, and it runs below the typed layer, before
routing. So the one thing an instrument needs -- which route matched -- is not something it
can see for itself. RouteFromContext publishes it.
func Tracing(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
route, ok := muzak.RouteFromContext(r.Context())
if !ok {
route = "unmatched" // a 404 is a 404, not traffic to a route named ""
}
status := 0
if recorder, ok := w.(muzak.StatusRecorder); ok {
status = recorder.Status()
}
record(route, status, time.Since(start))
})
}
Use the template, never the path. r.URL.Path is the concrete request:
/orgs/01a06391-0a5b-7695-af40-babc7981a63e/apps/01a063ad-d99f-7a05-98b6-d12f11b24ea3/keys
As a span name or a metric label that is one time series per organization per app. Unbounded cardinality is how a tracing backend falls over, and it makes the only question worth asking -- how slow is this endpoint -- unanswerable, because every request is its own endpoint. The template is bounded by the number of routes you wrote:
/orgs/{org_id}/apps/{app_id}/keys
Two rules about when to read it. After next.ServeHTTP returns, because before that
routing has not happened. And a request that matched nothing reports false rather than an
empty string, so a miss is counted as a miss.
StatusRecorder is the response writer the access log installs. The assertion can fail --
DisableAccessLog means there is no wrapper -- so middleware that needs the status either
way should fall back to wrapping the writer itself.
For tracing and request metrics you rarely need to write one. AppOptions.Tracing gives every
request a server span named after its route template and continues the W3C trace context a
request carries, and AppOptions.Observer takes a RequestObserver, which is called once for
every request after its response with the method, the route template, the status, the
duration and the sizes: the hook a Prometheus histogram or an OpenTelemetry meter is built on.
Neither adds a dependency. A Tracer is an interface, which the framework's otlp package
implements, and an observer is a type with one method. See
Observability. A middleware like the one above is for
whatever those two do not record.
The access log
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
AccessLogOptions: muzak.AccessLogOptions{
Level: slog.LevelInfo,
SkipPaths: []string{"/healthz"},
},
})
14:32:10.114 INFO [Request] GET /items/ status=200 route=/items/ duration=412µs bytes=181 request_id=0611f4b2
Successful responses are logged at Level. Server errors are always logged at error level
and client errors at warn level, so a quiet production level still surfaces failures.
SkipPaths lists exact paths that produce no line, which keeps a health check polled
every second from drowning out real traffic.
The liveness and readiness endpoints of AppOptions.Health need no entry here. They never
reach middleware installed with App.Use, and each probe writes its own line at
HealthOptions.AccessLogLevel, debug by default, so a production level already leaves them
out; listing their paths in SkipPaths silences them entirely. See
Health Checks.
A request that failed after its response had started, and so aborted the connection, is
marked aborted=true. A panic in a middleware is recorded as a 500. A path or a method
is quoted at most 1 KiB long in the log and in the 404 and 405 messages, so a request of
invalid bytes cannot write several times its size to the client and the log store.
route is the template the request matched, beside the concrete path in the message. The
path is what somebody reading a log wants; the template is what a query groups by, and what
joins a log line to the trace and the metric for the same endpoint.
Only fixed, non-sensitive fields are recorded. Query strings, request bodies and headers are deliberately omitted, because each of them routinely carries credentials or personal data that should not be duplicated into a log store.
Request identifiers
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
TrustRequestIDHeader: true,
})
By default an inbound X-Request-Id is ignored and a fresh UUID version 7 is generated,
because an attacker-controlled identifier is an attacker-controlled log field. With the
option on, an inbound value is honoured only if it parses as a UUID, so it can never carry
newlines or control characters into a log line.
Inside a handler the identifier is ctx.RequestID(). In code that has only a
context.Context, such as a repository or a client wrapper, it is
muzak.RequestIDFromContext(ctx).
Writing your own
// ProcessTime reports how long the service spent on a request in an
// X-Process-Time header, in seconds.
func ProcessTime() muzak.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(&processTimer{ResponseWriter: w, start: time.Now()}, r)
})
}
}
// processTimer stamps the elapsed time onto the response as it starts.
type processTimer struct {
http.ResponseWriter
start time.Time
stamped bool
}
// WriteHeader records the duration and forwards the status.
func (w *processTimer) WriteHeader(status int) {
if !w.stamped {
w.stamped = true
elapsed := time.Since(w.start).Seconds()
w.Header().Set("X-Process-Time", strconv.FormatFloat(elapsed, 'f', 6, 64))
}
w.ResponseWriter.WriteHeader(status)
}
// Write stamps a response whose handler never set a status, which net/http
// treats as a 200.
func (w *processTimer) Write(b []byte) (int, error) {
if !w.stamped {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(b)
}
// Flush passes a flush through to whatever is underneath, so that installing
// this middleware cannot stop a handler from streaming.
func (w *processTimer) Flush() {
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
// Unwrap exposes the underlying writer to http.ResponseController.
func (w *processTimer) Unwrap() http.ResponseWriter { return w.ResponseWriter }
The one thing that fails quietly
The obvious shape for that middleware does not work, and it fails without a word:
start := time.Now()
next.ServeHTTP(w, r)
w.Header().Set("X-Process-Time", elapsed(start)) // too late
That reads correctly and does nothing. Go puts the header block on the wire at the first
WriteHeader, which the handler has already called by the time control comes back, so the
value is written to a map nobody will read again and no error is reported. Middleware that reports something only known
at the end has to wrap the writer and fill the value in as the response starts, which is
what processTimer above does.
Two details make a wrapper well behaved:
- Implement
Writeas well asWriteHeader, because a handler that writes a body without setting a status never callsWriteHeaderitself. - Implement
Unwrap() http.ResponseWriter, sohttp.ResponseControllercan still reach the real writer. Without it, a wrapper breaks flushing, hijacking and the deadline handling that WebSockets and event streams depend on. A wrapper that forwardsFlushand has noUnwraphides the connection's deadlines, so an event stream'sWriteTimeoutis silently not enforced and a client that stopped reading holds its stream indefinitely. The stream is still served, but the first one on each route logs a warning naming the writer that stopped the chain.
Middleware and the typed layer
Middleware runs before routing, so it cannot see the matched route, the bound input or the resolved dependencies. Anything that needs those belongs in a guard or a provider, which run per route and can refuse the request with a typed error. See Dependencies.
A whole http.Handler, such as net/http/pprof or a metrics handler, is not middleware
either. Router.Mount serves one at a prefix, inside this chain and behind the guards, providers
and rate limit of its router. See Mounting Handlers.
Where to go next
Lifecycle covers the resources a request depends on, and Compression covers the one piece of middleware most applications add.