diff --git a/go.mod b/go.mod index f8d2090..d28ed64 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel go 1.23.4 require ( + gitea.libretechconsulting.com/rmcguire/go-app v0.3.0 github.com/caarlos0/env/v9 v9.0.0 github.com/rs/zerolog v1.33.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 @@ -15,11 +16,12 @@ require ( go.opentelemetry.io/otel/sdk v1.33.0 go.opentelemetry.io/otel/sdk/metric v1.33.0 go.opentelemetry.io/otel/trace v1.33.0 - golang.org/x/sys v0.28.0 + golang.org/x/sys v0.29.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/caarlos0/env/v11 v11.3.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/klauspost/compress v1.17.11 // indirect ) @@ -42,11 +44,11 @@ require ( go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect go.opentelemetry.io/otel/metric v1.33.0 - go.opentelemetry.io/proto/otlp v1.4.0 // indirect - golang.org/x/net v0.33.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect + golang.org/x/net v0.34.0 // indirect golang.org/x/text v0.21.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422 // indirect google.golang.org/grpc v1.69.2 // indirect - google.golang.org/protobuf v1.36.1 // indirect + google.golang.org/protobuf v1.36.2 // indirect ) diff --git a/go.sum b/go.sum index 7237727..e8b14b8 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +gitea.libretechconsulting.com/rmcguire/go-app v0.3.0 h1:TSR6oEDBX+83975gmgGgU/cTFgfG999+9N/1h4RAXq0= +gitea.libretechconsulting.com/rmcguire/go-app v0.3.0/go.mod h1:wHOWh4O4AMDATQ3WEUYjq5a5bnICPBpu5G6BsNxqN38= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc= github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -87,25 +91,37 @@ go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qq go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d h1:H8tOf8XM88HvKqLTxe755haY6r1fqqzLbEnfrmLXlSA= google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d/go.mod h1:2v7Z7gP2ZUOGsaFyxATQSRoBnKygqVq2Cwnvom7QiqY= +google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= +google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw= google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422 h1:3UsHvIr4Wc2aW4brOaSCmcxh9ksica6fHEr8P1XhkYw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250106144421-5f5ef82da422/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4= google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.2 h1:R8FeyR1/eLmkutZOM5CWghmo5itiG9z0ktFlTVLuTmU= +google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/main.go b/main.go index 4a2d2f7..2264b06 100644 --- a/main.go +++ b/main.go @@ -19,9 +19,9 @@ import ( "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/srv" + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/app" + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/config" + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv" ) var ( diff --git a/pkg/app/app.go b/pkg/app/app.go deleted file mode 100644 index 5550f14..0000000 --- a/pkg/app/app.go +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 59c85fb..0000000 --- a/pkg/app/run.go +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 66df414..0000000 --- a/pkg/app/setup.go +++ /dev/null @@ -1,25 +0,0 @@ -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/config/config.go b/pkg/config/config.go deleted file mode 100644 index e919b1b..0000000 --- a/pkg/config/config.go +++ /dev/null @@ -1,69 +0,0 @@ -package config - -import ( - "context" - "flag" - "fmt" - "os" - "runtime/debug" - - "github.com/caarlos0/env/v9" - "gopkg.in/yaml.v3" -) - -// To be set by ldflags in go build command or -// retrieved from build meta below -var Version = "(devel)" - -// Calling this will try to load from config if -config is -// provided as a file, and will apply any environment overrides -// on-top of configuration defaults. -// Config is stored in returned context, and can be retrieved -// using config.FromCtx(ctx) -func LoadConfig(ctx context.Context) (context.Context, error) { - configPath := flag.String("config", "", "Path to the configuration file") - flag.Parse() - - // Start with defaults - // Load from config if provided - // Layer on environment - cfg, err := loadConfig(*configPath) - if err != nil { - return ctx, err - } - - // Add config to context, and return - // an updated context - ctx = cfg.AddToCtx(ctx) - return ctx, nil -} - -func loadConfig(configPath string) (*AppConfig, error) { - cfg := newAppConfig() - - if configPath != "" { - file, err := os.Open(configPath) - if err != nil { - return nil, fmt.Errorf("could not open config file: %w", err) - } - defer file.Close() - - decoder := yaml.NewDecoder(file) - if err := decoder.Decode(cfg); err != nil { - return nil, fmt.Errorf("could not decode config file: %w", err) - } - } - - if err := env.Parse(cfg); err != nil { - return nil, fmt.Errorf("could not parse environment variables: %w", err) - } - - return cfg, nil -} - -func getVersion() string { - if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "(devel)" { - return info.Main.Version - } - return Version -} diff --git a/pkg/config/ctx.go b/pkg/config/ctx.go deleted file mode 100644 index b451469..0000000 --- a/pkg/config/ctx.go +++ /dev/null @@ -1,36 +0,0 @@ -package config - -import ( - "context" - "errors" -) - -type appConfigKey uint8 - -const appConfigCtxKey appConfigKey = iota - -func (a *AppConfig) AddToCtx(ctx context.Context) context.Context { - return context.WithValue(ctx, appConfigCtxKey, a) -} - -func MustFromCtx(ctx context.Context) *AppConfig { - cfg, err := FromCtx(ctx) - if err != nil { - panic(err) - } - return cfg -} - -func FromCtx(ctx context.Context) (*AppConfig, error) { - ctxData := ctx.Value(appConfigCtxKey) - if ctxData == nil { - return nil, errors.New("no config found in context") - } - - cfg, ok := ctxData.(*AppConfig) - if !ok { - return nil, errors.New("invalid config stored in context") - } - - return cfg, nil -} diff --git a/pkg/config/types.go b/pkg/config/types.go deleted file mode 100644 index bf8d32a..0000000 --- a/pkg/config/types.go +++ /dev/null @@ -1,70 +0,0 @@ -package config - -func newAppConfig() *AppConfig { - return &AppConfig{ - Version: getVersion(), - Logging: &LogConfig{}, - HTTP: &HTTPConfig{}, - OTEL: &OTELConfig{}, - } -} - -type AppConfig struct { - Name string `yaml:"name" env:"APP_NAME" envDefault:"go-http-server-with-otel"` - Environment string `yaml:"environment" env:"APP_ENVIRONMENT" envDefault:"development"` - // This should either be set by ldflags, such as with - // go build -ldflags "-X gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/config.Version=$(VERSION)" - // or allow this to use build meta. Will default to (devel) - Version string `yaml:"version" env:"APP_VERSION"` - Logging *LogConfig `yaml:"logging"` - HTTP *HTTPConfig `yaml:"http"` - OTEL *OTELConfig `yaml:"otel"` -} - -// Logging Configuration -type LogConfig struct { - Enabled bool `yaml:"enabled" env:"APP_LOG_ENABLED" envDefault:"true"` - Level string `yaml:"level" env:"APP_LOG_LEVEL" envDefault:"info"` - Format LogFormat `yaml:"format" env:"APP_LOG_FORMAT" envDefault:"json"` - Output LogOutput `yaml:"output" env:"APP_LOG_OUTPUT" envDefault:"stderr"` - TimeFormat TimeFormat `yaml:"timeFormat" env:"APP_LOG_TIME_FORMAT" envDefault:"short"` -} - -type LogFormat string - -const ( - LogFormatConsole LogFormat = "console" - LogFormatJSON LogFormat = "json" -) - -type TimeFormat string - -const ( - TimeFormatShort TimeFormat = "short" - TimeFormatLong TimeFormat = "long" - TimeFormatUnix TimeFormat = "unix" - TimeFormatRFC3339 TimeFormat = "rfc3339" - TimeFormatOff TimeFormat = "off" -) - -type LogOutput string - -const ( - LogOutputStdout LogOutput = "stdout" - LogOutputStderr LogOutput = "stderr" -) - -// HTTP Configuration -type HTTPConfig struct { - Listen string `yaml:"listen" env:"APP_HTTP_LISTEN" envDefault:"127.0.0.1:8080"` - RequestTimeout int `yaml:"requestTimeout" env:"APP_HTTP_REQUEST_TIMEOUT" envDefault:"30"` -} - -// OTEL Configuration -type OTELConfig struct { - Enabled bool `yaml:"enabled" env:"APP_OTEL_ENABLED" envDefault:"true"` - PrometheusEnabled bool `yaml:"prometheusEnabled" env:"APP_OTEL_PROMETHEUS_ENABLED" envDefault:"true"` - PrometheusPath string `yaml:"prometheusPath" env:"APP_OTEL_PROMETHEUS_PATH" envDefault:"/metrics"` - StdoutEnabled bool `yaml:"stdoutEnabled" env:"APP_OTEL_STDOUT_ENABLED" envDefault:"false"` - MetricIntervalSecs int `yaml:"metricIntervalSecs" env:"APP_OTEL_METRIC_INTERVAL_SECS" envDefault:"15"` -} diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go deleted file mode 100644 index 58c86e8..0000000 --- a/pkg/logging/logging.go +++ /dev/null @@ -1,72 +0,0 @@ -package logging - -import ( - "context" - "os" - "time" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - - "gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/config" -) - -func MustInitLogging(ctx context.Context) context.Context { - cfg := config.MustFromCtx(ctx) - - logger, err := configureLogger(cfg.Logging) - if err != nil { - panic(err) - } - - return logger.WithContext(ctx) -} - -func configureLogger(cfg *config.LogConfig) (*zerolog.Logger, error) { - setTimeFormat(cfg.TimeFormat) - - // Default JSON logger - logger := zerolog.New(os.Stderr) - if cfg.TimeFormat != config.TimeFormatOff { - logger = logger.With().Timestamp().Logger() - } - - // Pretty console logger - if cfg.Format == config.LogFormatConsole { - consoleWriter := zerolog.ConsoleWriter{ - Out: os.Stderr, - TimeFormat: zerolog.TimeFieldFormat, - } - if cfg.TimeFormat == config.TimeFormatOff { - consoleWriter.FormatTimestamp = func(_ interface{}) string { - return "" - } - } - logger = log.Output(consoleWriter) - } - - level, err := zerolog.ParseLevel(cfg.Level) - if err != nil { - level = zerolog.InfoLevel - } - - logger = logger.Level(level) - zerolog.SetGlobalLevel(level) - - return &logger, err -} - -func setTimeFormat(format config.TimeFormat) { - switch format { - case config.TimeFormatShort: - zerolog.TimeFieldFormat = time.Kitchen - case config.TimeFormatUnix: - zerolog.TimeFieldFormat = zerolog.TimeFormatUnix - case config.TimeFormatLong: - zerolog.TimeFieldFormat = time.DateTime - case config.TimeFormatRFC3339: - zerolog.TimeFieldFormat = time.RFC3339 - case config.TimeFormatOff: - zerolog.TimeFieldFormat = "" - } -} diff --git a/pkg/otel/ctx.go b/pkg/otel/ctx.go deleted file mode 100644 index 4597ea7..0000000 --- a/pkg/otel/ctx.go +++ /dev/null @@ -1,54 +0,0 @@ -package otel - -import ( - "context" - "errors" - - "go.opentelemetry.io/otel/metric" - "go.opentelemetry.io/otel/trace" -) - -type otelCtxKey uint8 - -const ( - ctxKeyTracer otelCtxKey = iota - ctxKeyMeter -) - -func MustTracerFromCtx(ctx context.Context) trace.Tracer { - ctxData := ctx.Value(ctxKeyTracer) - if ctxData == nil { - panic(errors.New("no tracer found in context")) - } - - tracer, ok := ctxData.(trace.Tracer) - if !ok { - panic(errors.New("invalid tracer found in context")) - } - - return tracer -} - -func AddTracerToCtx(ctx context.Context, tracer trace.Tracer) context.Context { - ctx = context.WithValue(ctx, ctxKeyTracer, tracer) - return ctx -} - -func MustMeterFromCtx(ctx context.Context) metric.Meter { - ctxData := ctx.Value(ctxKeyMeter) - if ctxData == nil { - panic(errors.New("no meter found in context")) - } - - meter, ok := ctxData.(metric.Meter) - if !ok { - panic(errors.New("invalid meter found in context")) - } - - return meter -} - -func AddMeterToCtx(ctx context.Context, meter metric.Meter) context.Context { - ctx = context.WithValue(ctx, ctxKeyMeter, meter) - return ctx -} diff --git a/pkg/otel/otel.go b/pkg/otel/otel.go deleted file mode 100644 index 72cd18a..0000000 --- a/pkg/otel/otel.go +++ /dev/null @@ -1,227 +0,0 @@ -package otel - -import ( - "context" - "errors" - "fmt" - "os" - "time" - - opentelemetry "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" - "go.opentelemetry.io/otel/exporters/prometheus" - "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" - "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" - - "gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/config" - - noopMetric "go.opentelemetry.io/otel/metric/noop" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/sdk/metric" - "go.opentelemetry.io/otel/sdk/resource" - traceSDK "go.opentelemetry.io/otel/sdk/trace" - semconv "go.opentelemetry.io/otel/semconv/v1.24.0" - trace "go.opentelemetry.io/otel/trace" - "go.opentelemetry.io/otel/trace/noop" -) - -// OTEL Options -var ( - EnableStdoutExporter Option = enableStdoutExporter{} - EnablePrometheusExporter Option = enablePrometheusExporter{} - // Overide the default metric export interval - WithMetricExportInterval = func(interval time.Duration) Option { - return exportInterval{interval: interval} - } -) - -const defMetricInterval = 15 * time.Second - -// Context must carry config.AppConfig -func Init(ctx context.Context) (context.Context, func(context.Context) error) { - cfg := config.MustFromCtx(ctx) - - // Nothing to do here if not enabled - if !cfg.OTEL.Enabled { - opentelemetry.SetMeterProvider(noopMetric.NewMeterProvider()) - opentelemetry.SetTracerProvider(noop.NewTracerProvider()) - return ctx, func(context.Context) error { - return nil - } - } - - var ( - metricInterval = defMetricInterval - options = make([]Option, 0) - s = &settings{} - shutdownFuncs []func(context.Context) error - ) - - // Prepare settings for OTEL from configuration - if cfg.OTEL.StdoutEnabled { - options = append(options, EnableStdoutExporter) - } - if cfg.OTEL.MetricIntervalSecs > 0 { - metricInterval = time.Duration(cfg.OTEL.MetricIntervalSecs) * time.Second - } - if cfg.OTEL.PrometheusEnabled { - options = append(options, EnablePrometheusExporter) - } - options = append(options, - WithMetricExportInterval(metricInterval)) - - // Apply settings - for _, opt := range options { - opt.apply(s) - } - - // shutdown calls cleanup functions registered via shutdownFuncs. - // The errors from the calls are joined. - // Each registered cleanup will be invoked once. - shutdown := func(ctx context.Context) error { - var err error - for _, fn := range shutdownFuncs { - err = errors.Join(err, fn(ctx)) - } - shutdownFuncs = nil - return err - } - - // handleErr calls shutdown for cleanup and makes sure that all errors are returned. - handleErr := func(inErr error) { - if err := errors.Join(inErr, shutdown(ctx)); err != nil { - fmt.Fprintln(os.Stderr, "OTEL Error:", err) - } - } - - // Set up meter provider. - meterProvider, err := s.newMeterProvider(ctx) - if err != nil { - handleErr(err) - return ctx, shutdown - } - - shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown) - opentelemetry.SetMeterProvider(meterProvider) - - meter := opentelemetry.Meter(cfg.Name) - ctx = AddMeterToCtx(ctx, meter) - - // Set up tracing - opentelemetry.SetTextMapPropagator(newPropagator()) - var tracerProvider *traceSDK.TracerProvider - tracerProvider, err = s.newTracerProvider(ctx) - if err != nil { - handleErr(err) - return ctx, shutdown - } - shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown) - opentelemetry.SetTracerProvider(tracerProvider) - - tracer := opentelemetry.Tracer(cfg.Name) - ctx = AddTracerToCtx(ctx, tracer) - - return ctx, shutdown -} - -func newPropagator() propagation.TextMapPropagator { - return propagation.NewCompositeTextMapPropagator( - propagation.TraceContext{}, - propagation.Baggage{}, - ) -} - -func (s *settings) newTracerProvider(ctx context.Context) (traceProvider *traceSDK.TracerProvider, err error) { - traceOpts := []traceSDK.TracerProviderOption{ - traceSDK.WithResource(newResource()), - } - - host, set := os.LookupEnv("OTEL_EXPORTER_OTLP_ENDPOINT") - if set && host != "" { - exporter, err := otlptracegrpc.New(ctx) - if err != nil { - return nil, err - } - traceOpts = append(traceOpts, traceSDK.WithBatcher(exporter)) - } - - if s.EnableStdoutExporter { - stdoutExporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint()) - if err != nil { - return nil, err - } - traceOpts = append(traceOpts, traceSDK.WithBatcher(stdoutExporter)) - } - - traceProvider = traceSDK.NewTracerProvider(traceOpts...) - - return -} - -func newResource() *resource.Resource { - return resource.NewWithAttributes(semconv.SchemaURL) -} - -// Configures meter provider -// Always provides a prometheus metrics exporter -// Conditionally provides an OTLP metrics exporter -func (s *settings) newMeterProvider(ctx context.Context) (*metric.MeterProvider, error) { - // OTEL Prometheus Exporter - exporter, err := prometheus.New() - if err != nil { - return nil, err - } - - metricOptions := make([]metric.Option, 0, 5) - if s.EnableStdoutExporter { - stdoutMetricExporter, err := stdoutmetric.New() - if err != nil { - return nil, err - } - metricOptions = append(metricOptions, - metric.WithReader(metric.NewPeriodicReader(stdoutMetricExporter)), - ) - } - - host, set := os.LookupEnv("OTEL_EXPORTER_OTLP_ENDPOINT") - var otlpExporter *otlpmetricgrpc.Exporter - if set && host != "" { - if exp, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithInsecure()); err != nil { - return nil, fmt.Errorf("otlpmetricgrpc.New: %w", err) - } else { - otlpExporter = exp - } - } - - var meterProvider *metric.MeterProvider - if otlpExporter != nil { - metricOptions = append(metricOptions, - metric.WithReader(exporter), - metric.WithReader( - metric.NewPeriodicReader( - otlpExporter, - metric.WithInterval(s.MetricExportInterval), - ), - ), - metric.WithResource(newResource()), - ) - } else { - metricOptions = append(metricOptions, - metric.WithReader(exporter), - metric.WithResource(newResource()), - ) - } - - meterProvider = metric.NewMeterProvider(metricOptions...) - - return meterProvider, nil -} - -// Creates a new tracer from the global opentelemetry provider -func NewTracer(options ...trace.TracerOption) trace.Tracer { - return opentelemetry.GetTracerProvider().Tracer( - os.Getenv("OTEL_SERVICE_NAME"), - options..., - ) -} diff --git a/pkg/otel/settings.go b/pkg/otel/settings.go deleted file mode 100644 index 3f12f2c..0000000 --- a/pkg/otel/settings.go +++ /dev/null @@ -1,38 +0,0 @@ -package otel - -import "time" - -type settings struct { - EnableStdoutExporter bool - EnablePrometheusExporter bool - MetricExportInterval time.Duration -} - -type Option interface { - apply(*settings) -} - -type enableStdoutExporter struct { - Option -} - -func (setting enableStdoutExporter) apply(o *settings) { - o.EnableStdoutExporter = true -} - -type enablePrometheusExporter struct { - Option -} - -func (setting enablePrometheusExporter) apply(o *settings) { - o.EnablePrometheusExporter = true -} - -type exportInterval struct { - Option - interval time.Duration -} - -func (setting exportInterval) apply(o *settings) { - o.MetricExportInterval = setting.interval -} diff --git a/pkg/otel/util.go b/pkg/otel/util.go deleted file mode 100644 index 720b7f7..0000000 --- a/pkg/otel/util.go +++ /dev/null @@ -1,30 +0,0 @@ -package otel - -import ( - "context" - "strings" - - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/metric" - "go.opentelemetry.io/otel/trace" - - "gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/config" -) - -func GetTracer(ctx context.Context, components ...string) trace.Tracer { - return otel.Tracer(getName(ctx, components...)) -} - -func GetMeter(ctx context.Context, components ...string) metric.Meter { - return otel.Meter(getName(ctx, components...)) -} - -func getName(ctx context.Context, components ...string) string { - cfg := config.MustFromCtx(ctx) - - path := make([]string, 0, len(components)+1) - path = append(path, cfg.Name) - path = append(path, components...) - - return strings.Join(path, ".") -} diff --git a/pkg/srv/http.go b/pkg/srv/http.go deleted file mode 100644 index 3ac1916..0000000 --- a/pkg/srv/http.go +++ /dev/null @@ -1,131 +0,0 @@ -package srv - -import ( - "context" - "net" - "net/http" - "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-http-server-with-otel/pkg/config" - "gitea.libretechconsulting.com/rmcguire/go-http-server-with-otel/pkg/otel" -) - -var ( - httpMeter metric.Meter - httpTracer trace.Tracer - readTimeout = 10 * time.Second - writeTimeout = 10 * time.Second - idleTimeout = 15 * time.Second -) - -type HTTPFunc struct { - Path string - HandlerFunc http.HandlerFunc -} - -func prepHTTPServer(ctx context.Context, handleFuncs []HTTPFunc, hcFuncs ...HealthCheckFunc) *http.Server { - var ( - cfg = config.MustFromCtx(ctx) - l = zerolog.Ctx(ctx) - mux = &http.ServeMux{} - ) - - // NOTE: Wraps handle func with otelhttp handler and - // inserts route tag - otelHandleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) { - handler := otelhttp.WithRouteTag(pattern, http.HandlerFunc(handlerFunc)) - mux.Handle(pattern, handler) // Associate pattern with handler - } - - healthChecks := handleHealthCheckFunc(ctx, hcFuncs...) - otelHandleFunc("/health", healthChecks) - otelHandleFunc("/", healthChecks) - - for _, f := range handleFuncs { - otelHandleFunc(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") - } - - // Add OTEL, skip health-check spans - // NOTE: Add any other span exclusions here - handler := otelhttp.NewHandler(mux, "/", - otelhttp.WithFilter(func(r *http.Request) bool { - switch r.URL.Path { - case "/health": - return false - case cfg.OTEL.PrometheusPath: - return false - default: - return true - } - })) - - return &http.Server{ - Addr: cfg.HTTP.Listen, - ReadTimeout: readTimeout, - WriteTimeout: writeTimeout, - IdleTimeout: idleTimeout, - Handler: handler, - BaseContext: func(_ net.Listener) context.Context { - return ctx - }, - } -} - -// Returns a shutdown func and a done channel if the -// server aborts abnormally. Panics on error. -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) - } - return shutdownFunc, doneChan -} - -// 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, hcFuncs ...HealthCheckFunc) ( - func(context.Context) error, <-chan interface{}, error, -) { - l := zerolog.Ctx(ctx) - doneChan := make(chan interface{}) - - var server *http.Server - - httpMeter = otel.GetMeter(ctx, "http") - httpTracer = otel.GetTracer(ctx, "http") - - server = prepHTTPServer(ctx, funcs, hcFuncs...) - - go func() { - l.Debug().Msg("HTTP Server Started") - err := server.ListenAndServe() - if err != nil && err != http.ErrServerClosed { - l.Err(err).Msg("HTTP server error") - } else { - l.Info().Msg("HTTP server shut down") - } - doneChan <- nil - }() - - // 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 -} diff --git a/pkg/srv/http_health.go b/pkg/srv/http_health.go deleted file mode 100644 index 8a31a90..0000000 --- a/pkg/srv/http_health.go +++ /dev/null @@ -1,67 +0,0 @@ -package srv - -import ( - "context" - "errors" - "math/rand" - "net/http" - "sync" - "time" - - "github.com/rs/zerolog" -) - -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 ( - healthChecksFailed bool - errs error - hcWg sync.WaitGroup - ) - - 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() - errs = errors.Join(errs, hc(ctx)) - }() - } - hcWg.Wait() - - if errs != nil { - healthChecksFailed = true - } - - if healthChecksFailed { - w.WriteHeader(http.StatusInternalServerError) - } - - if errs != nil { - w.Write([]byte(errs.Error())) - } else { - w.Write([]byte("ok")) - } - } -} - -func dummyHealthCheck(ctx context.Context) error { - workFor := rand.Intn(750) - ticker := time.NewTicker(time.Duration(time.Duration(workFor) * time.Millisecond)) - - select { - case <-ticker.C: - return nil - case <-ctx.Done(): - return ctx.Err() - } -}