Files
go-app/pkg/srv/http/http.go

183 lines
4.7 KiB
Go

// Package http provides functionality for setting up and managing HTTP servers.
// It includes features for health checks, Prometheus metrics, OpenTelemetry
// tracing, and custom middleware integration.
package http
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/config"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv/http/opts"
)
var (
httpMeter metric.Meter
httpTracer trace.Tracer
defReadTimeout = 10 * time.Second
defWriteTimeout = 10 * time.Second
defIdleTimeout = 15 * time.Second
)
func prepHTTPServer(opts *opts.AppHTTP) *http.Server {
var (
cfg = config.MustFromCtx(opts.Ctx)
l = zerolog.Ctx(opts.Ctx)
mux = &http.ServeMux{}
)
healthChecks := handleHealthCheckFunc(opts.Ctx, opts.HealthChecks...)
mux.HandleFunc("/health", healthChecks)
mux.HandleFunc("/", healthChecks)
for _, f := range opts.Funcs {
mux.HandleFunc(f.Path, f.HandlerFunc)
}
// Prometheus metrics endpoint
if cfg.OTEL.PrometheusEnabled {
mux.Handle(cfg.OTEL.PrometheusPath, promhttp.Handler())
l.Info().Str("prometheusPath", cfg.OTEL.PrometheusPath).
Msg("mounted prometheus metrics endpoint")
}
// Inject extra handlers if given
// Used for grpc-gateway runtime.ServeMux handlers
for _, h := range opts.Handlers {
// prefix must end in / to route sub-paths
if !strings.HasSuffix(h.Prefix, "/") {
h.Prefix = h.Prefix + "/"
}
// if enabled, the path prefix is stripped before
// requests are sent to the handler
if h.StripPrefix {
h.Handler = http.StripPrefix(h.Prefix[:len(h.Prefix)-1], h.Handler)
}
mux.Handle(h.Prefix, otelhttp.WithRouteTag(h.Prefix, h.Handler))
}
// Add OTEL instrumentation, filter noise, set span names
handler := otelhttp.NewHandler(mux, "/",
// TODO: Make configurable similar to config.http.LogExcludePathRegexps
otelhttp.WithFilter(func(r *http.Request) bool {
switch r.URL.Path {
case "/health":
return false
case cfg.OTEL.PrometheusPath:
return false
default:
return true
}
}),
otelhttp.WithSpanNameFormatter(func(operation string, r *http.Request) string {
_, pattern := mux.Handler(r)
if pattern != "" {
return pattern // Use the route pattern as the span name, e.g., "/users/{id}"
}
// Fallback to the default naming convention if no route is found.
return operation + " " + r.URL.Path
}))
// Set timeouts from defaults, override
// with config timeouts if set
readTimeout := defReadTimeout
writeTimeout := defWriteTimeout
idleTimeout := defIdleTimeout
rT, wT, iT := cfg.HTTP.Timeouts()
if rT != nil {
readTimeout = *rT
}
if wT != nil {
writeTimeout = *wT
}
if iT != nil {
idleTimeout = *iT
}
// Inject any supplied middleware
for i := len(opts.Middleware) - 1; i >= 0; i-- {
mw := opts.Middleware[i]
next := handler
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mw.ServeHTTP(w, r)
next.ServeHTTP(w, r)
})
}
// Inject logging middleware
if cfg.HTTP.LogRequests {
handler = loggingMiddleware(opts.Ctx, handler)
}
return &http.Server{
Addr: cfg.HTTP.Listen,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
Handler: handler,
ErrorLog: log.New(os.Stderr, fmt.Sprintf("Go-HTTP[%s]", cfg.Name), log.Flags()),
BaseContext: func(_ net.Listener) context.Context {
return opts.Ctx
},
}
}
// InitHTTPServer returns a shutdown func and a done channel if the
// server aborts abnormally. Returns error on failure to start.
func InitHTTPServer(opts *opts.AppHTTP) (
func(context.Context) error, <-chan any, error,
) {
l := zerolog.Ctx(opts.Ctx)
doneChan := make(chan any)
var server *http.Server
httpMeter = otel.GetMeter(opts.Ctx, "http")
httpTracer = otel.GetTracer(opts.Ctx, "http")
server = prepHTTPServer(opts)
go func() {
var err error
if opts.CustomListener != nil {
err = server.Serve(opts.CustomListener)
} else {
err = server.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
l.Err(err).Msg("HTTP server error")
} else {
l.Info().Msg("HTTP server shut down")
}
// Notify app initiator
doneChan <- nil
}()
l.Debug().Msg("HTTP Server Started")
// Shut down http server with a deadline
return func(shutdownCtx context.Context) error {
l.Debug().Msg("stopping http server")
server.Shutdown(shutdownCtx)
return nil
}, doneChan, nil
}