On this page

Observability

MuxMaster ships with two first-class observability primitives — a structured Logger middleware (built on log/slog) and a RequestID middleware that attaches an X-Request-Id header to every response. Everything else (metrics, tracing, profiling) is intentionally operator-supplied: the router exposes the hooks, and you bring the backend (Prometheus, OpenTelemetry, Datadog, etc.). This page documents the recommended integration patterns.

Why no built-in metrics or tracing?

The router runs in many wildly different deployments: high-throughput edge proxies, internal microservices, CLI-served admin UIs. A built-in Prometheus exporter would force a dependency on github.com/prometheus/client_golang (violating MuxMaster's zero-deps invariant); a built-in OpenTelemetry SDK would have the same problem. By keeping the surface to http.Handler and per-request slog events, you can plug any observability stack with a thin middleware of your own — with no abandoned defaults to migrate away from later.

Structured logging (slog)

middleware.Logger writes one structured event per request. It uses log/slog from the stdlib so the output format is controlled by your default *slog.Logger (text vs JSON, level filter, custom handlers).

import "log/slog"

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))
slog.SetDefault(logger)

mux := muxmaster.New()
mux.Pre(middleware.RequestID())              // attach X-Request-Id first
mux.Use(middleware.Logger(os.Stdout))         // logs method, path, status, duration
mux.Use(middleware.RecovererWithLogger(logger))

Each Logger event includes:

Field Type Notes
time RFC 3339 Set by slog
level string INFO for normal requests, WARN/ERROR for ≥500 responses
method string Sanitised (CRLF-stripped — HPS-2026-0001)
path string r.URL.Path (sanitised; param values are NOT included)
status int Captured response status
duration string time.Duration.String() of the handler execution

If you need to add custom fields (tenant ID, user agent, response size) write your own middleware that wraps http.ResponseWriter and emits a slog event of its own — Logger is intentionally minimal so it composes cleanly.

Request correlation

middleware.RequestID generates a 16-byte cryptographically random ID (crypto/rand) per request, attaches it as X-Request-Id on the response, and stores it in the request context.

mux.Pre(middleware.RequestID())

mux.GET("/users/:id", func(w http.ResponseWriter, r *http.Request) {
    rid, _ := middleware.GetRequestID(r.Context())
    slog.InfoContext(r.Context(), "user lookup",
        "request_id", rid,
        "user_id", muxmaster.PathParam(r, "id"),
    )
})

If a client supplies its own X-Request-Id, the middleware respects it (after sanitisation). Pass RequestID() via Pre(...) so the ID is attached BEFORE any other middleware logs the request.

Custom metrics middleware (Prometheus pattern)

The recommended pattern is a single user-side middleware that captures the per-route latency and increments counters. MuxMaster's Mux.Routes() and Mux.Walk() introspection let you pre-register counters at startup so the cardinality is bounded.

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    reqCount = prometheus.NewCounterVec(
        prometheus.CounterOpts{Name: "http_requests_total"},
        []string{"method", "route", "status"},
    )
    reqDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Buckets: prometheus.DefBuckets,
        },
        []string{"method", "route"},
    )
)

func init() {
    prometheus.MustRegister(reqCount, reqDuration)
}

// Metrics records one observation per request, keyed on the matched
// route pattern (NOT the raw URL — that would explode cardinality).
func Metrics(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rec := &statusRecorder{ResponseWriter: w, status: 200}
        next.ServeHTTP(rec, r)
        route := muxmaster.RoutePattern(r) // bounded label cardinality
        reqCount.WithLabelValues(r.Method, route, strconv.Itoa(rec.status)).Inc()
        reqDuration.WithLabelValues(r.Method, route).Observe(time.Since(start).Seconds())
    })
}

type statusRecorder struct {
    http.ResponseWriter
    status int
}

func (r *statusRecorder) WriteHeader(code int) {
    r.status = code
    r.ResponseWriter.WriteHeader(code)
}

Register the metrics middleware via Use (it must observe the matched route pattern, which is only available inside the dispatch frame). Then expose /metrics:

mux.Use(Metrics)
mux.GET("/metrics", promhttp.Handler().ServeHTTP)

muxmaster.RoutePattern(r) returns the matched pattern (e.g. /users/:id) — using the raw r.URL.Path would create one Prometheus series per unique URL and pin the metric server's heap.

Distributed tracing (OpenTelemetry pattern)

The same middleware-injection model applies to OpenTelemetry. Wrap Pre so the span boundary covers the entire dispatch (including HandleFast routes):

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/trace"
)

func Tracing(tracer trace.Tracer, prop propagation.TextMapPropagator) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx := prop.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
            ctx, span := tracer.Start(ctx, muxmaster.RoutePattern(r))
            defer span.End()
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

mux.Pre(Tracing(otel.Tracer("api"), otel.GetTextMapPropagator()))

Operator notes:

  • Span name uses RoutePattern (not raw URL) for the same cardinality reason as metrics.
  • Place Tracing in Pre, BEFORE RequestID, so the trace ID is the source of truth and RequestID becomes a fallback only. If you need both (legacy callers without W3C trace headers), have your handler emit the OTel trace ID into the X-Request-Id response header so log-trace correlation works in either direction.

Health checks

Add a couple of fast routes that bypass middleware:

// /healthz returns 200 unconditionally — used by k8s liveness probes.
mux.GETFast("/healthz", func(w http.ResponseWriter, _ *http.Request, _ muxmaster.Params) {
    w.WriteHeader(http.StatusOK)
})

// /readyz returns 503 until startup is complete (e.g. DB pool warm).
mux.GETFast("/readyz", func(w http.ResponseWriter, _ *http.Request, _ muxmaster.Params) {
    if !ready.Load() {
        w.WriteHeader(http.StatusServiceUnavailable)
        return
    }
    w.WriteHeader(http.StatusOK)
})

Health endpoints registered as HandleFast skip the Use(...) chain entirely — useful so they remain reachable even if JWTAuth or ThrottlePerIP is misbehaving.

pprof / runtime introspection

The stdlib net/http/pprof package registers handlers on http.DefaultServeMux; route them onto MuxMaster manually:

import (
    "net/http/pprof"
)

// Mount pprof on a separate, internal-only Mux so it is NEVER exposed
// publicly. Bind to 127.0.0.1 or a private VPC interface.
debug := muxmaster.New()
debug.GET("/debug/pprof/",        pprof.Index)
debug.GET("/debug/pprof/cmdline", pprof.Cmdline)
debug.GET("/debug/pprof/profile", pprof.Profile)
debug.GET("/debug/pprof/symbol",  pprof.Symbol)
debug.GET("/debug/pprof/trace",   pprof.Trace)
debug.GET("/debug/pprof/:profile", pprof.Index) // heap, goroutine, …

go http.ListenAndServe("127.0.0.1:6060", debug)

Mux.Routes() and Mux.Walk() are also useful for an internal admin endpoint that lists every registered route:

debug.GET("/debug/routes", func(w http.ResponseWriter, _ *http.Request) {
    for _, route := range mainRouter.Routes() {
        fmt.Fprintf(w, "%-8s %s\n", route.Method, route.Pattern)
    }
})

Putting it together

A production-ready stack typically looks like this:

mux := muxmaster.New()

// Pre — runs OUTSIDE dispatch; covers Handle and HandleFast routes.
mux.Pre(Tracing(tracer, propagator))               // span boundary
mux.Pre(middleware.RequestID())                    // X-Request-Id
mux.Pre(middleware.RecovererWithLogger(logger))    // panic safety net
mux.Pre(middleware.RealIP(&trustedProxyCIDR))      // before throttle

// Use — runs INSIDE dispatch on stdlib (Handle) routes.
mux.Use(middleware.Timeout(5 * time.Second))
mux.Use(middleware.ThrottlePerIP(100, time.Second, nil))
mux.Use(middleware.Logger(os.Stdout))
mux.Use(Metrics)                                   // your custom Prometheus mw

See examples/graceful-shutdown for a self-contained program demonstrating signal-driven shutdown, the recommended http.Server timeouts, and a cooperative handler that yields to context cancellation.

  • Performance — measured throughput and allocation profile under realistic load.
  • Middleware — full reference for built-in middleware, including Logger, RequestID, Recoverer, Timeout, and the four scopes (Pre, Use, group, per-route).
  • SECURITY.md — operator-required defaults (http.Server timeouts, RealIP trusted CIDRs, JWTAuth RequireExpiry, OAuth2 HTTPS endpoint).

Upstream source

The introspection API (Routes(), Stats(), conflict reporting) is implemented in introspection.go in the upstream repository.

Common questions

How do I list every route registered on a router?

Call mux.Routes() (or m.Routes()) to obtain a slice describing every registered method/pattern pair, the order in which they were registered, and the middleware stack each handler sees. The result is suitable for emitting on /admin/routes or for a startup sanity check.

How do I detect route conflicts at startup?

The router rejects conflicting registrations at registration time — m.GET panics with a structured error that names the two patterns and the conflicting segment. Wrap the registration block in a startup recover (or write the registration in a func init) to surface conflicts as a clean exit instead of a goroutine crash.

How do I read runtime statistics from a live router?

Call mux.Stats() to obtain hit counters per route, plus the global request total and the count of 404s. The map is stable for the lifetime of the process and is suitable to expose on a metrics endpoint or to scrape periodically.