diff --git a/env-sample b/env-sample new file mode 100644 index 0000000..d6330c5 --- /dev/null +++ b/env-sample @@ -0,0 +1,14 @@ +# App Config +APP_NAME="go-http-server-with-otel" +APP_LOG_LEVEL=trace ## For testing only +APP_LOG_FORMAT=console ## console, json +APP_LOG_TIME_FORMAT=long ## long, short, unix, rfc3339, off + +# App OTEL Config +APP_OTEL_STDOUT_ENABLED=true ## For testing only +APP_OTEL_METRIC_INTERVAL_SECS=15 + +# OTEL SDK Config +OTEL_EXPORTER_OTLP_ENDPOINT="otel-collector.otel.svc.cluster.local" # Set to your otel collector +OTEL_SERVICE_NAME="go-http-server-with-otel" +OTEL_RESOURCE_ATTRIBUTES="env=development,service.version=(devel)" diff --git a/main.go b/main.go index 5302361..4a2d2f7 100644 --- a/main.go +++ b/main.go @@ -14,17 +14,13 @@ import ( "net/http" "os" "os/signal" - "sync" - "time" "github.com/rs/zerolog" - "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "golang.org/x/sys/unix" + "gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/app" "gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/config" - "gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/logging" - "gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/otel" "gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/srv" ) @@ -38,100 +34,29 @@ func main() { ctx, cncl := signal.NotifyContext(context.Background(), os.Interrupt, unix.SIGTERM) defer cncl() - shutdownFuncs := make([]func(context.Context) error, 0) - // Load configuration and setup logging - ctx = setupConfigAndLogging(ctx) + ctx = app.MustSetupConfigAndLogging(ctx) - // Set up OTEL - ctx, otelShutdown := otel.Init(ctx) - shutdownFuncs = append(shutdownFuncs, otelShutdown) - tracer = otel.MustTracerFromCtx(ctx) - - // Start App - ctx, initSpan := tracer.Start(ctx, "init") - - // Start HTTP Server - dummyFuncs := []srv.HTTPFunc{ - { - Path: "/dummy", - HandlerFunc: func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("hello world")) + // Prepare app + app := &app.App{ + AppContext: ctx, + HTTP: &app.AppHTTP{ + Funcs: []srv.HTTPFunc{ + { + Path: "/test", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) + w.Write([]byte("http test route")) + }, + }, }, }, } - httpShutdown, httpDone := srv.MustInitHTTPServer(ctx, dummyFuncs...) - shutdownFuncs = append(shutdownFuncs, httpShutdown) - // Startup Complete - l.Info(). - Str("name", cfg.Name). - Str("version", cfg.Version). - Str("logLevel", cfg.Logging.Level). - Msg("app initialized") - initSpan.SetStatus(codes.Ok, "") - initSpan.End() + // Launch app + app.MustRun() - // Wait for signal - select { - case <-httpDone: - l.Warn().Msg("shutting down early on http server done") - case <-ctx.Done(): - l.Warn().Str("reason", ctx.Err().Error()). - Msg("shutting down on context done") - } - shutdown(shutdownFuncs...) -} - -func shutdown(shutdownFuncs ...func(context.Context) error) { - now := time.Now() - - doneCtx, cncl := context.WithTimeout(context.Background(), 15*time.Second) - defer func() { - if doneCtx.Err() == context.DeadlineExceeded { - l.Err(doneCtx.Err()). - Dur("shutdownTime", time.Since(now)). - Msg("app shutdown aborted") - } else { - l.Info(). - Int("shutdownFuncsCalled", len(shutdownFuncs)). - Dur("shutdownTime", time.Since(now)). - Msg("app shutdown normally") - } - cncl() - }() - - doneCtx, span := tracer.Start(doneCtx, "shutdown") - defer span.End() - - var wg sync.WaitGroup - wg.Add(len(shutdownFuncs)) - - for _, f := range shutdownFuncs { - go func() { - defer wg.Done() - err := f(doneCtx) - if err != nil { - span.SetStatus(codes.Error, "shutdown failed") - span.RecordError(err) - l.Err(err).Send() - } - }() - } - - wg.Wait() -} - -func setupConfigAndLogging(ctx context.Context) context.Context { - ctx, err := config.LoadConfig(ctx) - if err != nil { - panic(err) - } - - cfg = config.MustFromCtx(ctx) - ctx = logging.MustInitLogging(ctx) - l = zerolog.Ctx(ctx) - - l.Trace().Any("config", *cfg).Send() - return ctx + // Wait for app to complete + // Perform any extra shutdown here + <-app.Done() } diff --git a/pkg/app/app.go b/pkg/app/app.go new file mode 100644 index 0000000..5550f14 --- /dev/null +++ b/pkg/app/app.go @@ -0,0 +1,93 @@ +package app + +import ( + "context" + "errors" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + "gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/config" + "gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/otel" + "gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/srv" +) + +type App struct { + AppContext context.Context + HTTP *AppHTTP + cfg *config.AppConfig + l *zerolog.Logger + tracer trace.Tracer + shutdownFuncs []shutdownFunc + appDone chan interface{} +} + +type AppHTTP struct { + Funcs []srv.HTTPFunc + HealthChecks []srv.HealthCheckFunc + httpDone <-chan interface{} +} + +type ( + healthCheckFunc func(context.Context) error + shutdownFunc func(context.Context) error +) + +func (a *App) Done() <-chan interface{} { + return a.appDone +} + +func (a *App) MustRun() { + if a.cfg != nil { + panic(errors.New("already ran app trying to run")) + } + + // Set up app + a.cfg = config.MustFromCtx(a.AppContext) + a.l = zerolog.Ctx(a.AppContext) + a.shutdownFuncs = make([]shutdownFunc, 0) + a.appDone = make(chan interface{}) + a.HTTP.httpDone = make(chan interface{}) + + if len(a.HTTP.Funcs) < 1 { + a.l.Warn().Msg("no http funcs provided, only serving health and metrics") + } + + // Start OTEL + a.initOTEL() + var initSpan trace.Span + _, initSpan = a.tracer.Start(a.AppContext, "init") + + // Start HTTP + a.initHTTP() + + // Monitor app lifecycle + go a.run() + + // Startup Complete + a.l.Info(). + Str("name", a.cfg.Name). + Str("version", a.cfg.Version). + Str("logLevel", a.cfg.Logging.Level). + Msg("app initialized") + initSpan.SetStatus(codes.Ok, "") + initSpan.End() +} + +func (a *App) initHTTP() { + var httpShutdown shutdownFunc + httpShutdown, a.HTTP.httpDone = srv.MustInitHTTPServer( + a.AppContext, + a.HTTP.Funcs, + a.HTTP.HealthChecks..., + ) + a.shutdownFuncs = append(a.shutdownFuncs, httpShutdown) +} + +func (a *App) initOTEL() { + var otelShutdown shutdownFunc + a.AppContext, otelShutdown = otel.Init(a.AppContext) + a.shutdownFuncs = append(a.shutdownFuncs, otelShutdown) + a.tracer = otel.MustTracerFromCtx(a.AppContext) +} diff --git a/pkg/app/run.go b/pkg/app/run.go new file mode 100644 index 0000000..59c85fb --- /dev/null +++ b/pkg/app/run.go @@ -0,0 +1,67 @@ +package app + +import ( + "context" + "sync" + "time" + + "go.opentelemetry.io/otel/codes" +) + +// Watches contexts and channels for the +// app to be finished and calls shutdown once +// the app is done +func (a *App) run() { + select { + case <-a.AppContext.Done(): + a.l.Warn().Str("reason", a.AppContext.Err().Error()). + Msg("shutting down on context done") + case <-a.HTTP.httpDone: + a.l.Warn().Msg("shutting down early on http server done") + } + a.Shutdown() + + a.appDone <- nil +} + +// Typically invoked when AppContext is done +// or Server has exited. Not intended to be called +// manually +func (a *App) Shutdown() { + now := time.Now() + + doneCtx, cncl := context.WithTimeout(context.Background(), 15*time.Second) + defer func() { + if doneCtx.Err() == context.DeadlineExceeded { + a.l.Err(doneCtx.Err()). + Dur("shutdownTime", time.Since(now)). + Msg("app shutdown aborted") + } else { + a.l.Info(). + Int("shutdownFuncsCalled", len(a.shutdownFuncs)). + Dur("shutdownTime", time.Since(now)). + Msg("app shutdown normally") + } + cncl() + }() + + doneCtx, span := a.tracer.Start(doneCtx, "shutdown") + defer span.End() + + var wg sync.WaitGroup + wg.Add(len(a.shutdownFuncs)) + + for _, f := range a.shutdownFuncs { + go func() { + defer wg.Done() + err := f(doneCtx) + if err != nil { + span.SetStatus(codes.Error, "shutdown failed") + span.RecordError(err) + a.l.Err(err).Send() + } + }() + } + + wg.Wait() +} diff --git a/pkg/app/setup.go b/pkg/app/setup.go new file mode 100644 index 0000000..66df414 --- /dev/null +++ b/pkg/app/setup.go @@ -0,0 +1,25 @@ +package app + +import ( + "context" + + "github.com/rs/zerolog" + + "gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/config" + "gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/logging" +) + +// Helper function to return a context loaded up with +// config.AppConfig and a logger +func MustSetupConfigAndLogging(ctx context.Context) context.Context { + ctx, err := config.LoadConfig(ctx) + if err != nil { + panic(err) + } + + cfg := config.MustFromCtx(ctx) + ctx = logging.MustInitLogging(ctx) + + zerolog.Ctx(ctx).Trace().Any("config", *cfg).Send() + return ctx +} diff --git a/pkg/srv/http.go b/pkg/srv/http.go index 32681c3..3ac1916 100644 --- a/pkg/srv/http.go +++ b/pkg/srv/http.go @@ -29,7 +29,7 @@ type HTTPFunc struct { HandlerFunc http.HandlerFunc } -func prepHTTPServer(ctx context.Context, handleFuncs ...HTTPFunc) *http.Server { +func prepHTTPServer(ctx context.Context, handleFuncs []HTTPFunc, hcFuncs ...HealthCheckFunc) *http.Server { var ( cfg = config.MustFromCtx(ctx) l = zerolog.Ctx(ctx) @@ -43,8 +43,9 @@ func prepHTTPServer(ctx context.Context, handleFuncs ...HTTPFunc) *http.Server { mux.Handle(pattern, handler) // Associate pattern with handler } - otelHandleFunc("/health", handleHealthCheckFunc(ctx)) - otelHandleFunc("/", handleHealthCheckFunc(ctx)) + healthChecks := handleHealthCheckFunc(ctx, hcFuncs...) + otelHandleFunc("/health", healthChecks) + otelHandleFunc("/", healthChecks) for _, f := range handleFuncs { otelHandleFunc(f.Path, f.HandlerFunc) @@ -85,8 +86,10 @@ func prepHTTPServer(ctx context.Context, handleFuncs ...HTTPFunc) *http.Server { // Returns a shutdown func and a done channel if the // server aborts abnormally. Panics on error. -func MustInitHTTPServer(ctx context.Context, funcs ...HTTPFunc) (func(context.Context) error, <-chan interface{}) { - shutdownFunc, doneChan, err := InitHTTPServer(ctx, funcs...) +func MustInitHTTPServer(ctx context.Context, funcs []HTTPFunc, hcFuncs ...HealthCheckFunc) ( + func(context.Context) error, <-chan interface{}, +) { + shutdownFunc, doneChan, err := InitHTTPServer(ctx, funcs, hcFuncs...) if err != nil { panic(err) } @@ -95,7 +98,9 @@ func MustInitHTTPServer(ctx context.Context, funcs ...HTTPFunc) (func(context.Co // Returns a shutdown func and a done channel if the // server aborts abnormally. Returns error on failure to start -func InitHTTPServer(ctx context.Context, funcs ...HTTPFunc) (func(context.Context) error, <-chan interface{}, error) { +func InitHTTPServer(ctx context.Context, funcs []HTTPFunc, hcFuncs ...HealthCheckFunc) ( + func(context.Context) error, <-chan interface{}, error, +) { l := zerolog.Ctx(ctx) doneChan := make(chan interface{}) @@ -104,7 +109,7 @@ func InitHTTPServer(ctx context.Context, funcs ...HTTPFunc) (func(context.Contex httpMeter = otel.GetMeter(ctx, "http") httpTracer = otel.GetTracer(ctx, "http") - server = prepHTTPServer(ctx, funcs...) + server = prepHTTPServer(ctx, funcs, hcFuncs...) go func() { l.Debug().Msg("HTTP Server Started") diff --git a/pkg/srv/http_health.go b/pkg/srv/http_health.go index a9ea37c..8a31a90 100644 --- a/pkg/srv/http_health.go +++ b/pkg/srv/http_health.go @@ -7,39 +7,47 @@ import ( "net/http" "sync" "time" + + "github.com/rs/zerolog" ) -func handleHealthCheckFunc(_ context.Context) func(w http.ResponseWriter, r *http.Request) { +type HealthCheckFunc func(context.Context) error + +func handleHealthCheckFunc(ctx context.Context, hcFuncs ...HealthCheckFunc) func(w http.ResponseWriter, r *http.Request) { // Return http handle func return func(w http.ResponseWriter, r *http.Request) { - var err error - var healthChecksFailed bool + var ( + healthChecksFailed bool + errs error + hcWg sync.WaitGroup + ) - // TODO: Insert useful health checks here - // For multiple checks, perform concurrently - // Consider using errors.Join() for multiple checks - var hcWg sync.WaitGroup - for range 5 { - hcWg.Add(1) + if len(hcFuncs) < 1 { + zerolog.Ctx(ctx).Warn().Msg("no health checks given responding with dummy 200") + hcFuncs = append(hcFuncs, dummyHealthCheck) + } + + // Run all health check funcs concurrently + // log all errors + hcWg.Add(len(hcFuncs)) + for _, hc := range hcFuncs { go func() { defer hcWg.Done() - err = errors.Join(err, dummyHealthCheck(r.Context())) + errs = errors.Join(errs, hc(ctx)) }() } hcWg.Wait() - if err != nil { + + if errs != nil { healthChecksFailed = true } - // TODO: Friendly reminder... - err = errors.New("WARNING: Unimplemented health-check") - if healthChecksFailed { w.WriteHeader(http.StatusInternalServerError) } - if err != nil { - w.Write([]byte(err.Error())) + if errs != nil { + w.Write([]byte(errs.Error())) } else { w.Write([]byte("ok")) }