// Package ekko provides an enhanced Echo web framework wrapper with pre-configured middleware. // // This package wraps the Echo web framework with a comprehensive middleware stack including: // - OpenTelemetry distributed tracing with request context propagation // - Prometheus metrics collection with per-service subsystems // - Structured logging with trace ID correlation // - Security headers (HSTS, content security policy) // - Gzip compression for response optimization // - Recovery middleware with detailed error logging // - HTTP/2 support with H2C (HTTP/2 Cleartext) capability // // The package uses functional options pattern for flexible configuration // and supports graceful shutdown with configurable timeouts. It's designed // as the standard web service foundation for NTP Pool project services. // // Example usage: // // ekko, err := ekko.New("myservice", // ekko.WithPort(8080), // ekko.WithPrometheus(prometheus.DefaultRegisterer), // ekko.WithEchoSetup(func(e *echo.Echo) error { // e.GET("/health", healthHandler) // return nil // }), // ) // if err != nil { // log.Fatal(err) // } // err = ekko.Start(ctx) package ekko import ( "context" "fmt" "net" "net/http" "time" "github.com/labstack/echo-contrib/echoprometheus" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" slogecho "github.com/samber/slog-echo" "go.ntppool.org/common/logger" "go.ntppool.org/common/version" "go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "golang.org/x/net/http2" "golang.org/x/sync/errgroup" ) // New creates a new Ekko instance with the specified service name and functional options. // The name parameter is used for OpenTelemetry service identification, Prometheus metrics // subsystem naming, and server identification headers. // // Default configuration includes: // - 60 second write timeout // - 30 second read header timeout // - HTTP/2 support with H2C // - Standard middleware stack (tracing, metrics, logging, security) // // Use functional options to customize behavior: // - WithPort(): Set server port (required for Start()) // - WithPrometheus(): Enable Prometheus metrics // - WithEchoSetup(): Configure routes and handlers // - WithLogFilters(): Filter access logs // - WithOtelMiddleware(): Custom OpenTelemetry middleware // - WithWriteTimeout(): Custom write timeout // - WithReadHeaderTimeout(): Custom read header timeout // - WithGzipConfig(): Custom gzip compression settings func New(name string, options ...func(*Ekko)) (*Ekko, error) { ek := &Ekko{ writeTimeout: 60 * time.Second, readHeaderTimeout: 30 * time.Second, } for _, o := range options { o(ek) } return ek, nil } // SetupEcho creates and configures an Echo instance without starting the server. // This method is primarily intended for testing scenarios where you need access // to the configured Echo instance without starting the HTTP server. // // The returned Echo instance includes all configured middleware and routes // but requires manual server lifecycle management. func (ek *Ekko) SetupEcho(ctx context.Context) (*echo.Echo, error) { return ek.setup(ctx) } // Start creates the Echo instance and starts the HTTP server with graceful shutdown support. // The server runs until either an error occurs or the provided context is cancelled. // // The server supports HTTP/2 with H2C (HTTP/2 Cleartext) and includes a 5-second // graceful shutdown timeout when the context is cancelled. Server configuration // (port, timeouts, middleware) must be set via functional options during New(). // // Returns an error if server startup fails or if shutdown doesn't complete within // the timeout period. Returns nil for clean shutdown via context cancellation. func (ek *Ekko) Start(ctx context.Context) error { log := logger.Setup() e, err := ek.setup(ctx) if err != nil { return err } g, ctx := errgroup.WithContext(ctx) g.Go(func() error { e.Server.Addr = fmt.Sprintf(":%d", ek.port) log.Info("server starting", "port", ek.port) // err := e.Server.ListenAndServe() err := e.StartH2CServer(e.Server.Addr, &http2.Server{}) if err == http.ErrServerClosed { return nil } return err }) g.Go(func() error { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return e.Shutdown(shutdownCtx) }) return g.Wait() } func (ek *Ekko) setup(ctx context.Context) (*echo.Echo, error) { log := logger.Setup() e := echo.New() e.Server.ReadHeaderTimeout = ek.readHeaderTimeout e.Server.WriteTimeout = ek.writeTimeout e.Server.BaseContext = func(_ net.Listener) context.Context { return ctx } trustOptions := []echo.TrustOption{ echo.TrustLoopback(true), echo.TrustLinkLocal(false), echo.TrustPrivateNet(true), } e.IPExtractor = echo.ExtractIPFromXFFHeader(trustOptions...) if ek.otelmiddleware == nil { e.Use(otelecho.Middleware(ek.name)) } else { e.Use(ek.otelmiddleware) } e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{ LogErrorFunc: func(c echo.Context, err error, stack []byte) error { log.ErrorContext(c.Request().Context(), err.Error(), "stack", string(stack)) fmt.Println(string(stack)) return err }, })) e.Use(slogecho.NewWithConfig(log, slogecho.Config{ WithTraceID: false, // done by logger already Filters: ek.logFilters, }, )) if ek.prom != nil { e.Use(echoprometheus.NewMiddlewareWithConfig(echoprometheus.MiddlewareConfig{ Subsystem: ek.name, Registerer: ek.prom, })) } if ek.gzipConfig != nil { e.Use(middleware.GzipWithConfig(*ek.gzipConfig)) } else { e.Use(middleware.Gzip()) } secureConfig := middleware.DefaultSecureConfig // secureConfig.ContentSecurityPolicy = "default-src *" secureConfig.ContentSecurityPolicy = "" secureConfig.HSTSMaxAge = int(time.Hour * 168 * 30 / time.Second) secureConfig.HSTSPreloadEnabled = true e.Use(middleware.SecureWithConfig(secureConfig)) e.Use( func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { request := c.Request() span := trace.SpanFromContext(request.Context()) if span.IsRecording() { span.SetAttributes(attribute.String("http.real_ip", c.RealIP())) span.SetAttributes(attribute.String("url.path", c.Request().RequestURI)) if q := c.QueryString(); len(q) > 0 { span.SetAttributes(attribute.String("url.query", q)) } c.Response().Header().Set("Traceparent", span.SpanContext().TraceID().String()) } return next(c) } }, ) e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { vinfo := version.VersionInfo() v := ek.name + "/" + vinfo.Version + "+" + vinfo.GitRevShort return func(c echo.Context) error { c.Response().Header().Set(echo.HeaderServer, v) return next(c) } }) if ek.routeFn != nil { err := ek.routeFn(e) if err != nil { return nil, err } } return e, nil }