diff --git a/ekko/ekko.go b/ekko/ekko.go new file mode 100644 index 0000000..a71328d --- /dev/null +++ b/ekko/ekko.go @@ -0,0 +1,152 @@ +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/sync/errgroup" +) + +func New(name string, options ...func(*Ekko)) (*Ekko, error) { + ek := &Ekko{} + for _, o := range options { + o(ek) + } + return ek, nil +} + +// Setup Echo; only intended for testing +func (ek *Ekko) SetupEcho(ctx context.Context) (*echo.Echo, error) { + return ek.setup(ctx) +} + +// Setup Echo and start the server. Will return if the http server +// returns or the context is done. +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() + 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() + + // todo: make these an option? + e.Server.ReadTimeout = 30 * time.Second + e.Server.WriteTimeout = 60 * time.Second + + 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(slogecho.NewWithConfig(log, + slogecho.Config{ + WithTraceID: false, // done by logger already + }, + )) + + if ek.prom != nil { + e.Use(echoprometheus.NewMiddlewareWithConfig(echoprometheus.MiddlewareConfig{ + Subsystem: ek.name, + Registerer: ek.prom, + })) + } + + e.Use(middleware.Secure()) + + 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) + } + }) + + // todo: do we want this? + 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)) + return err + }, + })) + + if ek.routeFn != nil { + err := ek.routeFn(e) + if err != nil { + return nil, err + } + } + + return e, nil +} diff --git a/ekko/options.go b/ekko/options.go new file mode 100644 index 0000000..4fd61f5 --- /dev/null +++ b/ekko/options.go @@ -0,0 +1,40 @@ +package ekko + +import ( + "github.com/labstack/echo/v4" + "github.com/prometheus/client_golang/prometheus" +) + +type Ekko struct { + name string + prom prometheus.Registerer + port int + routeFn func(e *echo.Echo) error + otelmiddleware echo.MiddlewareFunc +} + +type RouteFn func(e *echo.Echo) error + +func WithPort(port int) func(*Ekko) { + return func(ek *Ekko) { + ek.port = port + } +} + +func WithPrometheus(reg prometheus.Registerer) func(*Ekko) { + return func(ek *Ekko) { + ek.prom = reg + } +} + +func WithEchoSetup(rfn RouteFn) func(*Ekko) { + return func(ek *Ekko) { + ek.routeFn = rfn + } +} + +func WithOtelMiddleware(mw echo.MiddlewareFunc) func(*Ekko) { + return func(ek *Ekko) { + ek.otelmiddleware = mw + } +} diff --git a/go.mod b/go.mod index a052e0d..b2442ce 100644 --- a/go.mod +++ b/go.mod @@ -4,20 +4,23 @@ go 1.22.2 require ( github.com/abh/certman v0.4.0 + github.com/labstack/echo-contrib v0.17.1 github.com/labstack/echo/v4 v4.12.0 github.com/oklog/ulid/v2 v2.1.0 github.com/prometheus/client_golang v1.20.4 github.com/remychantenay/slog-otel v1.3.2 + github.com/samber/slog-echo v1.14.6 github.com/segmentio/kafka-go v0.4.47 github.com/spf13/cobra v1.8.1 go.opentelemetry.io/contrib/exporters/autoexport v0.55.0 + go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.55.0 go.opentelemetry.io/otel v1.30.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.30.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 go.opentelemetry.io/otel/sdk v1.30.0 go.opentelemetry.io/otel/trace v1.30.0 - golang.org/x/mod v0.19.0 + golang.org/x/mod v0.21.0 golang.org/x/sync v0.8.0 google.golang.org/grpc v1.66.2 ) @@ -29,6 +32,7 @@ require ( github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -42,6 +46,7 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.59.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/samber/lo v1.47.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect @@ -60,9 +65,11 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.30.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect golang.org/x/crypto v0.27.0 // indirect + golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/net v0.29.0 // indirect golang.org/x/sys v0.25.0 // indirect golang.org/x/text v0.18.0 // indirect + golang.org/x/time v0.6.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/go.sum b/go.sum index 019cdbb..37478f5 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -30,6 +32,8 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2 github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo-contrib v0.17.1 h1:7I/he7ylVKsDUieaGRZ9XxxTYOjfQwVzHzUYrNykfCU= +github.com/labstack/echo-contrib v0.17.1/go.mod h1:SnsCZtwHBAZm5uBSAtQtXQHI3wqEA73hvTn0bYMKnZA= github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -62,6 +66,12 @@ github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoG github.com/remychantenay/slog-otel v1.3.2 h1:ZBx8qnwfLJ6e18Vba4e9Xp9B7khTmpIwFsU1sAmActw= github.com/remychantenay/slog-otel v1.3.2/go.mod h1:gKW4tQ8cGOKoA+bi7wtYba/tcJ6Tc9XyQ/EW8gHA/2E= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= +github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= +github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= +github.com/samber/slog-echo v1.14.6 h1:KsijkbMbKL6vTb8pDJ05e4LspRg0GwRGawm2nELEBjE= +github.com/samber/slog-echo v1.14.6/go.mod h1:i8QlNMhE0rVr+Mjj5ZIm6DMuTQ87euvAL2jRAd5HNVY= github.com/segmentio/kafka-go v0.4.47 h1:IqziR4pA3vrZq7YdRxaT3w1/5fvIH5qpCwstUanQQB0= github.com/segmentio/kafka-go v0.4.47/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= @@ -89,6 +99,10 @@ go.opentelemetry.io/contrib/bridges/prometheus v0.55.0 h1:1oZYcP3wuazG3O1563m8cs go.opentelemetry.io/contrib/bridges/prometheus v0.55.0/go.mod h1:sU48aWFqiqBXo2RBtq7KarczkW8uK6RdIU54y4VzpZs= go.opentelemetry.io/contrib/exporters/autoexport v0.55.0 h1:8kNP8SX9id5TY2feLB+79aFxE0kqzh3KvjF1nAfGxVM= go.opentelemetry.io/contrib/exporters/autoexport v0.55.0/go.mod h1:WhcvzeuTOr58aYsJ7S4ubY1xMs0WXAPaqTQnxr8bRHk= +go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.55.0 h1:dJfUeXRQiU+7IhOeqXV7f1hJA47cCOBmCY8uyygIEZg= +go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.55.0/go.mod h1:Uk7Flfuk5HGTeggDwlwanunnSDcJydFRihfXT1Z5fEs= +go.opentelemetry.io/contrib/propagators/b3 v1.30.0 h1:vumy4r1KMyaoQRltX7cJ37p3nluzALX9nugCjNNefuY= +go.opentelemetry.io/contrib/propagators/b3 v1.30.0/go.mod h1:fRbvRsaeVZ82LIl3u0rIvusIel2UUf+JcaaIpy5taho= go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts= go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.6.0 h1:WYsDPt0fM4KZaMhLvY+x6TVXd85P/KNl3Ez3t+0+kGs= @@ -134,10 +148,16 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -177,6 +197,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/health/health_server.go b/health/health_server.go index dc2d766..bdb9c54 100644 --- a/health/health_server.go +++ b/health/health_server.go @@ -32,7 +32,7 @@ func (srv *Server) SetLogger(log *slog.Logger) { } func (srv *Server) Listen(ctx context.Context, port int) error { - srv.log.Info("Starting health listener", "port", port) + srv.log.Info("starting health listener", "port", port) serveMux := http.NewServeMux()