3 Commits

Author SHA1 Message Date
3e319b24fd support json schema 2025-03-22 14:02:39 -04:00
f0a699029a improve server startup 2025-03-07 17:19:42 -05:00
e178956eef improve grpc lifecycle 2025-03-07 17:04:46 -05:00
12 changed files with 137 additions and 53 deletions

2
go.mod
View File

@ -7,6 +7,7 @@ require (
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.1
github.com/prometheus/client_golang v1.21.1
github.com/rs/zerolog v1.33.0
github.com/swaggest/jsonschema-go v0.3.73
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0
go.opentelemetry.io/otel v1.35.0
@ -39,6 +40,7 @@ require (
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/swaggest/refl v1.3.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect

4
go.sum
View File

@ -75,6 +75,10 @@ github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggest/jsonschema-go v0.3.73 h1:gU1pBzF3pkZ1GDD3dRMdQoCjrA0sldJ+QcM7aSSPgvc=
github.com/swaggest/jsonschema-go v0.3.73/go.mod h1:qp+Ym2DIXHlHzch3HKz50gPf2wJhKOrAB/VYqLS2oJU=
github.com/swaggest/refl v1.3.0 h1:PEUWIku+ZznYfsoyheF97ypSduvMApYyGkYF3nabS0I=
github.com/swaggest/refl v1.3.0/go.mod h1:3Ujvbmh1pfSbDYjC6JGG7nMgPvpG0ehQL4iNonnLNbg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=

View File

@ -2,6 +2,7 @@ package app
import (
"errors"
"sync"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/attribute"
@ -17,8 +18,6 @@ func (a *App) Done() <-chan any {
func (a *App) MustRun() {
if a.cfg != nil {
panic(errors.New("already ran app trying to run"))
} else if !a.cfg.HTTP.Enabled && !a.cfg.GRPC.Enabled {
panic(errors.New("neither http nor grpc enabled, nothing to do"))
}
// Set up app
@ -28,35 +27,53 @@ func (a *App) MustRun() {
a.appDone = make(chan any)
a.HTTP.HTTPDone = make(chan any)
if !a.cfg.HTTPEnabled() && !a.cfg.GRPCEnabled() {
panic(errors.New("neither http nor grpc enabled, nothing to do"))
}
if len(a.HTTP.Funcs) < 1 {
a.l.Warn().Msg("no http funcs provided, only serving health and metrics")
}
// Start OTEL
// Registers a NO-OP provider if not enabled
a.initOTEL()
ctx, initSpan := a.tracer.Start(a.AppContext, "init")
defer initSpan.End()
var serverWG sync.WaitGroup
// Start HTTP (does not block)
if a.cfg.HTTP.Enabled {
if err := a.initHTTP(ctx); err != nil {
initSpan.RecordError(err)
initSpan.SetStatus(codes.Error, err.Error())
}
initSpan.AddEvent("http server started")
initSpan.SetAttributes(attribute.Int("http.handlers", len(a.HTTP.Funcs)))
if a.cfg.HTTPEnabled() {
serverWG.Add(1)
go func() {
defer serverWG.Done()
if err := a.initHTTP(ctx); err != nil {
initSpan.RecordError(err)
initSpan.SetStatus(codes.Error, err.Error())
}
initSpan.AddEvent("http server started")
initSpan.SetAttributes(attribute.Int("http.handlers", len(a.HTTP.Funcs)))
}()
}
// Start GRPC (does not block)
if a.cfg.GRPC.Enabled {
if err := a.initGRPC(ctx); err != nil {
initSpan.RecordError(err)
initSpan.SetStatus(codes.Error, err.Error())
}
initSpan.AddEvent("grpc server started")
initSpan.SetAttributes(attribute.Int("grpc.services", len(a.GRPC.Services)))
if a.cfg.GRPCEnabled() {
serverWG.Add(1)
go func() {
defer serverWG.Done()
if err := a.initGRPC(ctx); err != nil {
initSpan.RecordError(err)
initSpan.SetStatus(codes.Error, err.Error())
}
initSpan.AddEvent("grpc server started")
initSpan.SetAttributes(attribute.Int("grpc.services", len(a.GRPC.Services)))
}()
}
serverWG.Wait()
// Monitor app lifecycle
go a.run()

40
pkg/app/schema.go Normal file
View File

@ -0,0 +1,40 @@
package app
import (
"encoding/json"
js "github.com/swaggest/jsonschema-go"
)
// Generates json schema for app's config.AppConfig
func (app *App) Schema() ([]byte, error) {
r := js.Reflector{}
s, err := r.Reflect(*app.cfg)
if err != nil {
return nil, err
}
return json.MarshalIndent(s, "", " ")
}
// Generates json schema for custom config
// which embeds *config.AppConfig into it
// Panics if no *config.AppConfig is embedded into custom
// config type
//
// See swaggest/jsonschema-go for struct tag docs
func CustomSchema[T any](target T) ([]byte, error) {
if err := HasAppConfig(target); err != nil {
panic(err.Error())
}
r := js.Reflector{}
s, err := r.Reflect(target)
if err != nil {
return nil, err
}
return json.MarshalIndent(s, "", " ")
}

View File

@ -19,7 +19,7 @@ import (
// are up to the caller.
func MustLoadConfigInto[T any](ctx context.Context, into T) (context.Context, T) {
// Step 1: Check our custom type for required *config.AppConfig
if err := hasAppConfig(into); err != nil {
if err := HasAppConfig(into); err != nil {
panic(err)
}
@ -66,7 +66,7 @@ func setAppConfig[T any](target T, appConfig *config.AppConfig) error {
}
// Replace *config.AppConfig
for i := 0; i < v.NumField(); i++ {
for i := range v.NumField() {
field := v.Field(i)
if field.Type() == reflect.TypeOf((*config.AppConfig)(nil)) {
if !field.CanSet() {
@ -80,7 +80,7 @@ func setAppConfig[T any](target T, appConfig *config.AppConfig) error {
return errors.New("no *config.AppConfig field found in target struct")
}
func hasAppConfig[T any](target T) error {
func HasAppConfig[T any](target T) error {
v := reflect.ValueOf(target)
if v.Kind() != reflect.Ptr || v.IsNil() {
return errors.New("target must be a non-nil pointer to a struct")
@ -92,7 +92,7 @@ func hasAppConfig[T any](target T) error {
}
hasAppConfig := false
for i := 0; i < v.NumField(); i++ {
for i := range v.NumField() {
field := v.Type().Field(i)
if field.Type == reflect.TypeOf((*config.AppConfig)(nil)) {
hasAppConfig = true

View File

@ -11,14 +11,35 @@ var DefaultConfig = &AppConfig{
}
type AppConfig struct {
Name string `yaml:"name,omitempty" env:"APP_NAME"`
Environment string `yaml:"environment,omitempty" env:"APP_ENVIRONMENT"`
Name string `yaml:"name,omitempty" env:"APP_NAME" json:"name,omitempty"`
Environment string `yaml:"environment,omitempty" env:"APP_ENVIRONMENT" json:"environment,omitempty"`
// This should either be set by ldflags, such as with
// go build -ldflags "-X gitea.libretechconsulting.com/rmcguire/go-app/pkg/config.Version=$(VERSION)"
// or allow this to use build meta. Will default to (devel)
Version string `yaml:"version,omitempty" env:"APP_VERSION"`
Logging *LogConfig `yaml:"logging,omitempty"`
HTTP *HTTPConfig `yaml:"http,omitempty"`
OTEL *OTELConfig `yaml:"otel,omitempty"`
GRPC *GRPCConfig `yaml:"grpc,omitempty"`
Version string `yaml:"version,omitempty" env:"APP_VERSION" json:"version,omitempty"`
Logging *LogConfig `yaml:"logging,omitempty" json:"logging,omitempty"`
HTTP *HTTPConfig `yaml:"http,omitempty" json:"http,omitempty"`
OTEL *OTELConfig `yaml:"otel,omitempty" json:"otel,omitempty"`
GRPC *GRPCConfig `yaml:"grpc,omitempty" json:"grpc,omitempty"`
}
func (ac *AppConfig) HTTPEnabled() bool {
if ac.HTTP != nil && ac.HTTP.Enabled {
return true
}
return false
}
func (ac *AppConfig) GRPCEnabled() bool {
if ac.GRPC != nil && ac.GRPC.Enabled {
return true
}
return false
}
func (ac *AppConfig) OTELEnabled() bool {
if ac.OTEL != nil && ac.OTEL.Enabled {
return true
}
return false
}

View File

@ -10,9 +10,9 @@ var defaultGRPCConfig = &GRPCConfig{
}
type GRPCConfig struct {
Enabled bool `yaml:"enabled" env:"APP_GRPC_ENABLED"`
Listen string `yaml:"listen" env:"APP_GRPC_LISTEN"`
LogRequests bool `yaml:"logRequests" env:"APP_GRPC_LOG_REQUESTS"`
EnableReflection bool `yaml:"enableReflection" env:"APP_GRPC_ENABLE_REFLECTION"`
EnableInstrumentation bool `yaml:"enableInstrumentation" env:"APP_GRPC_ENABLE_INSTRUMENTATION"` // requires OTEL
Enabled bool `yaml:"enabled" env:"APP_GRPC_ENABLED" json:"enabled,omitempty"`
Listen string `yaml:"listen" env:"APP_GRPC_LISTEN" json:"listen,omitempty"`
LogRequests bool `yaml:"logRequests" env:"APP_GRPC_LOG_REQUESTS" json:"logRequests,omitempty"`
EnableReflection bool `yaml:"enableReflection" env:"APP_GRPC_ENABLE_REFLECTION" json:"enableReflection,omitempty"`
EnableInstrumentation bool `yaml:"enableInstrumentation" env:"APP_GRPC_ENABLE_INSTRUMENTATION" json:"enableInstrumentation,omitempty"` // requires OTEL
}

View File

@ -14,13 +14,13 @@ var defaultHTTPConfig = &HTTPConfig{
// HTTP Configuration
type HTTPConfig struct {
Enabled bool `yaml:"enabled" env:"APP_HTTP_ENABLED"`
Listen string `yaml:"listen,omitempty" env:"APP_HTTP_LISTEN"`
LogRequests bool `yaml:"logRequests" env:"APP_HTTP_LOG_REQUESTS"`
ReadTimeout string `yaml:"readTimeout" env:"APP_HTTP_READ_TIMEOUT"` // Go duration (e.g. 10s)
WriteTimeout string `yaml:"writeTimeout" env:"APP_HTTP_WRITE_TIMEOUT"` // Go duration (e.g. 10s)
IdleTimeout string `yaml:"idleTimeout" env:"APP_HTTP_IDLE_TIMEOUT"` // Go duration (e.g. 10s)
rT *time.Duration `yaml:"rT" env:"rT"`
wT *time.Duration `yaml:"wT" env:"wT"`
iT *time.Duration `yaml:"iT" env:"iT"`
Enabled bool `yaml:"enabled" env:"APP_HTTP_ENABLED" json:"enabled,omitempty"`
Listen string `yaml:"listen,omitempty" env:"APP_HTTP_LISTEN" json:"listen,omitempty"`
LogRequests bool `yaml:"logRequests" env:"APP_HTTP_LOG_REQUESTS" json:"logRequests,omitempty"`
ReadTimeout string `yaml:"readTimeout" env:"APP_HTTP_READ_TIMEOUT" json:"readTimeout,omitempty"` // Go duration (e.g. 10s)
WriteTimeout string `yaml:"writeTimeout" env:"APP_HTTP_WRITE_TIMEOUT" json:"writeTimeout,omitempty"` // Go duration (e.g. 10s)
IdleTimeout string `yaml:"idleTimeout" env:"APP_HTTP_IDLE_TIMEOUT" json:"idleTimeout,omitempty"` // Go duration (e.g. 10s)
rT *time.Duration
wT *time.Duration
iT *time.Duration
}

View File

@ -10,11 +10,11 @@ var defaultLoggingConfig = &LogConfig{
// Logging Configuration
type LogConfig struct {
Enabled bool `yaml:"enabled,omitempty" env:"APP_LOG_ENABLED"`
Level string `yaml:"level,omitempty" env:"APP_LOG_LEVEL"`
Format LogFormat `yaml:"format,omitempty" env:"APP_LOG_FORMAT"`
Output LogOutput `yaml:"output,omitempty" env:"APP_LOG_OUTPUT"`
TimeFormat TimeFormat `yaml:"timeFormat,omitempty" env:"APP_LOG_TIME_FORMAT"`
Enabled bool `yaml:"enabled,omitempty" env:"APP_LOG_ENABLED" json:"enabled,omitempty"`
Level string `yaml:"level,omitempty" env:"APP_LOG_LEVEL" json:"level,omitempty"`
Format LogFormat `yaml:"format,omitempty" env:"APP_LOG_FORMAT" json:"format,omitempty"`
Output LogOutput `yaml:"output,omitempty" env:"APP_LOG_OUTPUT" json:"output,omitempty"`
TimeFormat TimeFormat `yaml:"timeFormat,omitempty" env:"APP_LOG_TIME_FORMAT" json:"timeFormat,omitempty"`
}
type LogFormat string

View File

@ -10,9 +10,9 @@ var defaultOTELConfig = &OTELConfig{
// OTEL Configuration
type OTELConfig struct {
Enabled bool `yaml:"enabled,omitempty" env:"APP_OTEL_ENABLED"`
PrometheusEnabled bool `yaml:"prometheusEnabled,omitempty" env:"APP_OTEL_PROMETHEUS_ENABLED"`
PrometheusPath string `yaml:"prometheusPath,omitempty" env:"APP_OTEL_PROMETHEUS_PATH"`
StdoutEnabled bool `yaml:"stdoutEnabled,omitempty" env:"APP_OTEL_STDOUT_ENABLED"`
MetricIntervalSecs int `yaml:"metricIntervalSecs,omitempty" env:"APP_OTEL_METRIC_INTERVAL_SECS"`
Enabled bool `yaml:"enabled,omitempty" env:"APP_OTEL_ENABLED" json:"enabled,omitempty"`
PrometheusEnabled bool `yaml:"prometheusEnabled,omitempty" env:"APP_OTEL_PROMETHEUS_ENABLED" json:"prometheusEnabled,omitempty"`
PrometheusPath string `yaml:"prometheusPath,omitempty" env:"APP_OTEL_PROMETHEUS_PATH" json:"prometheusPath,omitempty"`
StdoutEnabled bool `yaml:"stdoutEnabled,omitempty" env:"APP_OTEL_STDOUT_ENABLED" json:"stdoutEnabled,omitempty"`
MetricIntervalSecs int `yaml:"metricIntervalSecs,omitempty" env:"APP_OTEL_METRIC_INTERVAL_SECS" json:"metricIntervalSecs,omitempty"`
}

View File

@ -38,7 +38,7 @@ func configureLogger(cfg *config.LogConfig) (*zerolog.Logger, error) {
TimeFormat: zerolog.TimeFieldFormat,
}
if cfg.TimeFormat == config.TimeFormatOff {
consoleWriter.FormatTimestamp = func(_ interface{}) string {
consoleWriter.FormatTimestamp = func(_ any) string {
return ""
}
}

View File

@ -44,7 +44,7 @@ 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 {
if !cfg.OTELEnabled() {
opentelemetry.SetMeterProvider(noopMetric.NewMeterProvider())
opentelemetry.SetTracerProvider(noop.NewTracerProvider())
// Won't function with noop providers