Added app package and health check funcs

This commit is contained in:
Ryan McGuire 2025-01-04 11:23:24 -05:00
parent ce2a7ce221
commit bf3fa2c54c
7 changed files with 254 additions and 117 deletions

14
env-sample Normal file
View File

@ -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)"

109
main.go
View File

@ -14,17 +14,13 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"sync"
"time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace"
"golang.org/x/sys/unix" "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/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" "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) ctx, cncl := signal.NotifyContext(context.Background(), os.Interrupt, unix.SIGTERM)
defer cncl() defer cncl()
shutdownFuncs := make([]func(context.Context) error, 0)
// Load configuration and setup logging // Load configuration and setup logging
ctx = setupConfigAndLogging(ctx) ctx = app.MustSetupConfigAndLogging(ctx)
// Set up OTEL // Prepare app
ctx, otelShutdown := otel.Init(ctx) app := &app.App{
shutdownFuncs = append(shutdownFuncs, otelShutdown) AppContext: ctx,
tracer = otel.MustTracerFromCtx(ctx) HTTP: &app.AppHTTP{
Funcs: []srv.HTTPFunc{
// Start App
ctx, initSpan := tracer.Start(ctx, "init")
// Start HTTP Server
dummyFuncs := []srv.HTTPFunc{
{ {
Path: "/dummy", Path: "/test",
HandlerFunc: func(w http.ResponseWriter, r *http.Request) { HandlerFunc: func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello world")) w.WriteHeader(http.StatusNotImplemented)
w.Write([]byte("http test route"))
},
},
}, },
}, },
} }
httpShutdown, httpDone := srv.MustInitHTTPServer(ctx, dummyFuncs...)
shutdownFuncs = append(shutdownFuncs, httpShutdown)
// Startup Complete // Launch app
l.Info(). app.MustRun()
Str("name", cfg.Name).
Str("version", cfg.Version).
Str("logLevel", cfg.Logging.Level).
Msg("app initialized")
initSpan.SetStatus(codes.Ok, "")
initSpan.End()
// Wait for signal // Wait for app to complete
select { // Perform any extra shutdown here
case <-httpDone: <-app.Done()
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
} }

93
pkg/app/app.go Normal file
View File

@ -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)
}

67
pkg/app/run.go Normal file
View File

@ -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()
}

25
pkg/app/setup.go Normal file
View File

@ -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
}

View File

@ -29,7 +29,7 @@ type HTTPFunc struct {
HandlerFunc http.HandlerFunc HandlerFunc http.HandlerFunc
} }
func prepHTTPServer(ctx context.Context, handleFuncs ...HTTPFunc) *http.Server { func prepHTTPServer(ctx context.Context, handleFuncs []HTTPFunc, hcFuncs ...HealthCheckFunc) *http.Server {
var ( var (
cfg = config.MustFromCtx(ctx) cfg = config.MustFromCtx(ctx)
l = zerolog.Ctx(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 mux.Handle(pattern, handler) // Associate pattern with handler
} }
otelHandleFunc("/health", handleHealthCheckFunc(ctx)) healthChecks := handleHealthCheckFunc(ctx, hcFuncs...)
otelHandleFunc("/", handleHealthCheckFunc(ctx)) otelHandleFunc("/health", healthChecks)
otelHandleFunc("/", healthChecks)
for _, f := range handleFuncs { for _, f := range handleFuncs {
otelHandleFunc(f.Path, f.HandlerFunc) 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 // Returns a shutdown func and a done channel if the
// server aborts abnormally. Panics on error. // server aborts abnormally. Panics on error.
func MustInitHTTPServer(ctx context.Context, funcs ...HTTPFunc) (func(context.Context) error, <-chan interface{}) { func MustInitHTTPServer(ctx context.Context, funcs []HTTPFunc, hcFuncs ...HealthCheckFunc) (
shutdownFunc, doneChan, err := InitHTTPServer(ctx, funcs...) func(context.Context) error, <-chan interface{},
) {
shutdownFunc, doneChan, err := InitHTTPServer(ctx, funcs, hcFuncs...)
if err != nil { if err != nil {
panic(err) 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 // Returns a shutdown func and a done channel if the
// server aborts abnormally. Returns error on failure to start // 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) l := zerolog.Ctx(ctx)
doneChan := make(chan interface{}) doneChan := make(chan interface{})
@ -104,7 +109,7 @@ func InitHTTPServer(ctx context.Context, funcs ...HTTPFunc) (func(context.Contex
httpMeter = otel.GetMeter(ctx, "http") httpMeter = otel.GetMeter(ctx, "http")
httpTracer = otel.GetTracer(ctx, "http") httpTracer = otel.GetTracer(ctx, "http")
server = prepHTTPServer(ctx, funcs...) server = prepHTTPServer(ctx, funcs, hcFuncs...)
go func() { go func() {
l.Debug().Msg("HTTP Server Started") l.Debug().Msg("HTTP Server Started")

View File

@ -7,39 +7,47 @@ import (
"net/http" "net/http"
"sync" "sync"
"time" "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 http handle func
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var err error var (
var healthChecksFailed bool healthChecksFailed bool
errs error
hcWg sync.WaitGroup
)
// TODO: Insert useful health checks here if len(hcFuncs) < 1 {
// For multiple checks, perform concurrently zerolog.Ctx(ctx).Warn().Msg("no health checks given responding with dummy 200")
// Consider using errors.Join() for multiple checks hcFuncs = append(hcFuncs, dummyHealthCheck)
var hcWg sync.WaitGroup }
for range 5 {
hcWg.Add(1) // Run all health check funcs concurrently
// log all errors
hcWg.Add(len(hcFuncs))
for _, hc := range hcFuncs {
go func() { go func() {
defer hcWg.Done() defer hcWg.Done()
err = errors.Join(err, dummyHealthCheck(r.Context())) errs = errors.Join(errs, hc(ctx))
}() }()
} }
hcWg.Wait() hcWg.Wait()
if err != nil {
if errs != nil {
healthChecksFailed = true healthChecksFailed = true
} }
// TODO: Friendly reminder...
err = errors.New("WARNING: Unimplemented health-check")
if healthChecksFailed { if healthChecksFailed {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }
if err != nil { if errs != nil {
w.Write([]byte(err.Error())) w.Write([]byte(errs.Error()))
} else { } else {
w.Write([]byte("ok")) w.Write([]byte("ok"))
} }