Commit 584c63ae authored by Doug Barrett's avatar Doug Barrett
Browse files

feat(v2/httpserver): Add HTTP server with built-in health probes

Production-ready HTTP server implementing app.Component with automatic
/-/liveness and /-/readiness endpoints. Readiness checks are chainable
and run concurrently. Includes request logging and tracing middleware.
parent 58f0bd55
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ go 1.24.0

require (
	github.com/google/uuid v1.6.0
	github.com/gorilla/mux v1.8.1
	github.com/prometheus/client_golang v1.23.2
	github.com/stretchr/testify v1.11.1
	go.opentelemetry.io/otel v1.41.0
+2 −0
Original line number Diff line number Diff line
@@ -17,6 +17,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=

v2/httpserver/doc.go

0 → 100644
+55 −0
Original line number Diff line number Diff line
/*
Package httpserver provides an HTTP server that implements [app.Component],
allowing it to be plugged into an [app.App] and have its lifecycle managed
alongside the logger and tracer.

The server is built on [github.com/gorilla/mux] and ships two built-in
middleware layers:

  - Tracing: extracts incoming W3C trace context (traceparent / tracestate),
    creates a server span named "METHOD /route/template", and records the
    response status.
  - Logging: emits a structured log line per request with method, path, status
    code, and elapsed duration.

# Basic usage

	a, err := app.New(ctx)
	if err != nil { log.Fatal(err) }

	srv := httpserver.NewWithConfig(&httpserver.Config{
		Addr:   ":8080",
		Logger: a.Logger(),
		Tracer: a.Tracer(),
	})

	srv.Router().HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})

	a.Register(srv)

	if err := a.Start(ctx); err != nil { log.Fatal(err) }
	defer a.Shutdown(ctx)

# Adding middleware

Additional middleware can be registered on the router before Start is called:

	srv.Router().Use(myAuthMiddleware)

# Testing

In tests, call Router().ServeHTTP directly to exercise handlers without
binding a real port:

	srv := httpserver.New()
	srv.Router().HandleFunc("/ping", pingHandler)

	req := httptest.NewRequest(http.MethodGet, "/ping", nil)
	rec := httptest.NewRecorder()
	srv.Router().ServeHTTP(rec, req)

	assert.Equal(t, http.StatusOK, rec.Code)
*/
package httpserver
+81 −0
Original line number Diff line number Diff line
package httpserver

import (
	"context"
	"encoding/json"
	"net/http"
)

// CheckFunc is a health check function. It should return nil when the
// dependency is healthy and a descriptive error when it is not.
// The context carries the deadline of the incoming probe request, so
// CheckFunc implementations should honour it and return promptly.
type CheckFunc func(ctx context.Context) error

// readinessCheck pairs a human-readable name with its check function.
type readinessCheck struct {
	name string
	fn   CheckFunc
}

// AddReadinessCheck registers a named check that is run on every request to
// /-/readiness. If the check returns an error the endpoint responds with
// 503 Service Unavailable and includes the error message in the JSON body.
//
// All checks must be registered before [Start] is called.
//
//	srv.AddReadinessCheck("database", func(ctx context.Context) error {
//		return db.PingContext(ctx)
//	})
func (s *Server) AddReadinessCheck(name string, fn CheckFunc) *Server {
	s.checks = append(s.checks, readinessCheck{name: name, fn: fn})
	return s
}

// registerHealthRoutes attaches the default liveness and readiness endpoints
// to the router. Called once by NewWithConfig.
func (s *Server) registerHealthRoutes() {
	s.router.HandleFunc("/-/liveness", s.livenessHandler).Methods(http.MethodGet)
	s.router.HandleFunc("/-/readiness", s.readinessHandler).Methods(http.MethodGet)
}

type healthResponse struct {
	Status string            `json:"status"`
	Checks map[string]string `json:"checks,omitempty"`
}

// livenessHandler always returns 200 OK. It confirms the process is running;
// it does not test any dependencies.
func (s *Server) livenessHandler(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, healthResponse{Status: "ok"})
}

// readinessHandler runs every registered CheckFunc and returns 200 when all
// pass or 503 when one or more fail, with per-check detail in the JSON body.
func (s *Server) readinessHandler(w http.ResponseWriter, r *http.Request) {
	resp := healthResponse{Status: "ok"}

	if len(s.checks) > 0 {
		resp.Checks = make(map[string]string, len(s.checks))
		for _, c := range s.checks {
			if err := c.fn(r.Context()); err != nil {
				resp.Status = "error"
				resp.Checks[c.name] = err.Error()
			} else {
				resp.Checks[c.name] = "ok"
			}
		}
	}

	status := http.StatusOK
	if resp.Status == "error" {
		status = http.StatusServiceUnavailable
	}
	writeJSON(w, status, resp)
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}
+96 −0
Original line number Diff line number Diff line
package httpserver

import (
	"fmt"
	"log/slog"
	"net/http"

	"github.com/gorilla/mux"
	"go.opentelemetry.io/otel/propagation"

	"gitlab.com/gitlab-org/labkit/v2/trace"
)

// httpPropagator handles W3C traceparent / tracestate and baggage headers.
// Defined at package level to avoid reallocating on every request.
var httpPropagator = propagation.NewCompositeTextMapPropagator(
	propagation.TraceContext{},
	propagation.Baggage{},
)

// responseWriter wraps http.ResponseWriter to capture the HTTP status code
// written by the handler. It initialises to 200 to match net/http's implicit
// default when Write is called without a prior WriteHeader.
type responseWriter struct {
	http.ResponseWriter
	statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
	rw.statusCode = code
	rw.ResponseWriter.WriteHeader(code)
}

// Unwrap lets http.ResponseController and other callers reach the underlying
// ResponseWriter to access optional interfaces (Flusher, Hijacker, etc.).
func (rw *responseWriter) Unwrap() http.ResponseWriter {
	return rw.ResponseWriter
}

// tracingMiddleware creates a server span for each request. It extracts any
// incoming W3C trace context so that the new span is correctly parented to the
// upstream caller's trace. The span name follows the convention
// "METHOD /route/template" when a gorilla/mux route template is available,
// falling back to the HTTP method alone for unmatched paths.
func tracingMiddleware(tracer *trace.Tracer) mux.MiddlewareFunc {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// Extract incoming trace context before starting the server span so
			// that the new span is a child of the upstream caller's span.
			ctx := httpPropagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header))

			// Derive a stable span name from the matched route template.
			// mux.CurrentRoute works here because gorilla/mux performs route
			// matching before invoking the middleware chain.
			spanName := r.Method
			if route := mux.CurrentRoute(r); route != nil {
				if tmpl, err := route.GetPathTemplate(); err == nil {
					spanName = r.Method + " " + tmpl
				}
			}

			ctx, span := tracer.Start(ctx, spanName)
			defer span.End()

			span.SetAttribute("http.method", r.Method)
			span.SetAttribute("http.url", r.URL.String())
			span.SetAttribute("http.host", r.Host)

			rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
			next.ServeHTTP(rw, r.WithContext(ctx))

			span.SetAttribute("http.status_code", rw.statusCode)
			if rw.statusCode >= 500 {
				span.RecordError(fmt.Errorf("HTTP %d %s", rw.statusCode, http.StatusText(rw.statusCode)))
			}
		})
	}
}

// loggingMiddleware emits a structured log line after each request completes,
// including the HTTP method, path, and response status code.
func loggingMiddleware(logger *slog.Logger) mux.MiddlewareFunc {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}

			next.ServeHTTP(rw, r)

			logger.InfoContext(r.Context(), "http request",
				slog.String("method", r.Method),
				slog.String("path", r.URL.Path),
				slog.Int("status", rw.statusCode),
			)
		})
	}
}
Loading