diff --git a/.gitignore b/.gitignore index c371916..87af0e9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.exe *.test *.out +bin/ # Dependency directories vendor/ diff --git a/AGENTS.md b/AGENTS.md index d129493..7f33d5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -355,12 +355,37 @@ go test ./... go test ./pkg/greet/ ``` -### 5. Make Changes +### 5. Build Binaries + +The project uses a build script to compile binaries into the `bin/` directory: + +```bash +# Build both server and greet binaries +./scripts/build.sh + +# This creates: +# - ./bin/server - The web server binary +# - ./bin/greet - The CLI greeting tool +``` + +**Binary Usage:** +```bash +# Run the server +./bin/server + +# Use the greet CLI +./bin/greet # Output: Hello world! +./bin/greet John # Output: Hello John! +``` + +**The `bin/` directory is gitignored** to prevent binary files from being committed to the repository. + +### 6. Make Changes - Edit source files in `pkg/` or `cmd/` - Follow existing patterns and interfaces - Add tests for new functionality -### 6. Stop and Restart +### 7. Stop and Restart ```bash ./scripts/start-server.sh restart ``` diff --git a/cmd/greet/main.go b/cmd/greet/main.go index 8b967b3..3aa2367 100644 --- a/cmd/greet/main.go +++ b/cmd/greet/main.go @@ -14,6 +14,6 @@ func main() { if len(os.Args) > 1 { name = os.Args[1] } - + fmt.Println(service.Greet(context.Background(), name)) -} \ No newline at end of file +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 28614d4..3196aba 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,125 +2,26 @@ package main import ( "context" - "net" - "net/http" - "os" - "os/signal" - "syscall" - "time" "DanceLessonsCoach/pkg/config" "DanceLessonsCoach/pkg/server" - "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) func main() { - // Initialize Zerolog with default console format first - zerolog.SetGlobalLevel(zerolog.TraceLevel) - zerolog.TimeFieldFormat = zerolog.TimeFormatUnix - consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr} - - // Check if JSON logging is requested via environment variable - // This allows JSON logging even during config loading - jsonLogging := os.Getenv("DLC_LOGGING_JSON") == "true" - - if jsonLogging { - // JSON output for structured logging - log.Logger = log.Output(os.Stderr) - } else { - // Console output for initial logging - log.Logger = log.Output(consoleWriter) - } - - // Load configuration + // Load configuration (this will also setup logging) cfg, err := config.LoadConfig() if err != nil { log.Fatal().Err(err).Msg("Failed to load configuration") } - // Reconfigure logging based on loaded configuration (overrides env var) - if cfg.Logging.JSON { - // JSON output for structured logging - log.Logger = log.Output(os.Stderr) - } else { - // Keep console output - log.Logger = log.Output(consoleWriter) - } - - log.Info().Bool("json_logging", cfg.Logging.JSON).Msg("Logging configured") - - // Setup signal context for graceful shutdown - rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - // Create root context with cancellation - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Create ongoing context for active requests - ongoingCtx, stopOngoingGracefully := context.WithCancel(context.Background()) - // Create readiness context to control readiness state readyCtx, readyCancel := context.WithCancel(context.Background()) defer readyCancel() - // Start server in goroutine + // Create and run server server := server.NewServer(cfg, readyCtx) - serverCtx, serverStop := context.WithCancel(ctx) - - go func() { - log.Info().Str("address", cfg.GetServerAddress()).Msg("Server running") - - srv := &http.Server{ - Addr: cfg.GetServerAddress(), - Handler: server.Router(), - BaseContext: func(_ net.Listener) context.Context { - return ongoingCtx - }, - } - - // Start the HTTP server in a separate goroutine - go func() { - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Error().Err(err).Msg("Server error") - } - }() - - // Wait for signal - <-rootCtx.Done() - stop() - log.Info().Msg("Shutdown signal received") - - // Cancel readiness context to stop accepting new requests - readyCancel() - log.Info().Msg("Readiness set to false, no longer accepting new requests") - - // Give time for readiness check to propagate (simplified for our case) - time.Sleep(1 * time.Second) - log.Info().Msg("Readiness check propagated, now waiting for ongoing requests to finish.") - - // Create shutdown context with timeout from config - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.Shutdown.Timeout) - defer shutdownCancel() - - if err := srv.Shutdown(shutdownCtx); err != nil { - log.Error().Err(err).Msg("Server shutdown failed") - } else { - log.Info().Msg("Server shutdown complete") - } - - // Stop ongoing requests context - stopOngoingGracefully() - cancel() - serverStop() - log.Info().Msg("Server exited") - - // Force log flush by writing to stderr directly - // This ensures logs are written before process exits - time.Sleep(100 * time.Millisecond) - }() - - // Wait for shutdown - <-serverCtx.Done() -} \ No newline at end of file + if err := server.Run(); err != nil { + log.Fatal().Err(err).Msg("Server failed") + } +} diff --git a/config.example.yaml b/config.example.yaml index 2541417..f7c83f3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -21,10 +21,46 @@ logging: # Enable JSON output for structured logging (default: false) # When true, logs are output in JSON format instead of console format json: false + + # Log level (default: "trace") + # Options: "trace", "debug", "info", "warn", "error", "fatal", "panic" + level: trace + +# Telemetry configuration (OpenTelemetry) +telemetry: + # Enable OpenTelemetry tracing (default: false) + enabled: false + + # OTLP endpoint for trace export (default: "localhost:4317") + # Format: host:port + otlp_endpoint: "localhost:4317" + + # Service name for tracing (default: "DanceLessonsCoach") + service_name: "DanceLessonsCoach" + + # Use insecure connection (no TLS) (default: true) + insecure: true + + # Sampler configuration + sampler: + # Sampler type (default: "parentbased_always_on") + # Options: "always_on", "always_off", "traceidratio", "parentbased_always_on", "parentbased_always_off", "parentbased_traceidratio" + type: "parentbased_always_on" + + # Sampling ratio (0.0 to 1.0, default: 1.0) + # Only used with traceidratio and parentbased_traceidratio samplers + ratio: 1.0 # Environment Variables # You can also configure via environment variables with DLC_ prefix: # DLC_SERVER_HOST=0.0.0.0 # DLC_SERVER_PORT=8080 # DLC_SHUTDOWN_TIMEOUT=30s -# DLC_LOGGING_JSON=false \ No newline at end of file +# DLC_LOGGING_JSON=false +# DLC_LOGGING_LEVEL=trace +# DLC_TELEMETRY_ENABLED=true +# DLC_TELEMETRY_OTLP_ENDPOINT="jaeger:4317" +# DLC_TELEMETRY_SERVICE_NAME="DanceLessonsCoach" +# DLC_TELEMETRY_INSECURE=true +# DLC_TELEMETRY_SAMPLER_TYPE="parentbased_always_on" +# DLC_TELEMETRY_SAMPLER_RATIO=1.0 diff --git a/go.mod b/go.mod index 6f05129..c331f33 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,16 @@ module DanceLessonsCoach go 1.26.1 require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -17,7 +24,21 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.52.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 3f02cd8..8d1a325 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,24 @@ +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/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/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -26,11 +41,41 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/config/config.go b/pkg/config/config.go index f2d8c7c..d1aacc8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -3,24 +3,52 @@ package config import ( "fmt" "os" + "strings" "time" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/spf13/viper" ) // Config represents the application configuration type Config struct { - Server struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - } - Shutdown struct { - Timeout time.Duration `mapstructure:"timeout"` - } - Logging struct { - JSON bool `mapstructure:"json"` - } + Server ServerConfig `mapstructure:"server"` + Shutdown ShutdownConfig `mapstructure:"shutdown"` + Logging LoggingConfig `mapstructure:"logging"` + Telemetry TelemetryConfig `mapstructure:"telemetry"` +} + +// ServerConfig holds server-related configuration +type ServerConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` +} + +// ShutdownConfig holds shutdown-related configuration +type ShutdownConfig struct { + Timeout time.Duration `mapstructure:"timeout"` +} + +// LoggingConfig holds logging-related configuration +type LoggingConfig struct { + JSON bool `mapstructure:"json"` + Level string `mapstructure:"level"` +} + +// TelemetryConfig holds OpenTelemetry-related configuration +type TelemetryConfig struct { + Enabled bool `mapstructure:"enabled"` + OTLPEndpoint string `mapstructure:"otlp_endpoint"` + ServiceName string `mapstructure:"service_name"` + Insecure bool `mapstructure:"insecure"` + Sampler SamplerConfig `mapstructure:"sampler"` +} + +// SamplerConfig holds tracing sampler configuration +type SamplerConfig struct { + Type string `mapstructure:"type"` + Ratio float64 `mapstructure:"ratio"` } // LoadConfig loads configuration from file, environment variables, and defaults @@ -29,12 +57,25 @@ type Config struct { func LoadConfig() (*Config, error) { v := viper.New() + // Set up initial console logging for config loading messages + consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr} + log.Logger = log.Output(consoleWriter) + // Set default values v.SetDefault("server.host", "0.0.0.0") v.SetDefault("server.port", 8080) v.SetDefault("shutdown.timeout", 30*time.Second) v.SetDefault("logging.json", false) - + v.SetDefault("logging.level", "trace") + + // Telemetry defaults + v.SetDefault("telemetry.enabled", false) + v.SetDefault("telemetry.otlp_endpoint", "localhost:4317") + v.SetDefault("telemetry.service_name", "DanceLessonsCoach") + v.SetDefault("telemetry.insecure", true) + v.SetDefault("telemetry.sampler.type", "parentbased_always_on") + v.SetDefault("telemetry.sampler.ratio", 1.0) + // Check for custom config file path via environment variable if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" { v.SetConfigFile(configFile) @@ -45,7 +86,7 @@ func LoadConfig() (*Config, error) { v.SetConfigType("yaml") v.AddConfigPath(".") } - + // Read config file if it exists if err := v.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { @@ -56,7 +97,7 @@ func LoadConfig() (*Config, error) { } else { log.Info().Str("config_file", v.ConfigFileUsed()).Msg("Config file loaded") } - + // Bind environment variables v.AutomaticEnv() v.SetEnvPrefix("DLC") // DanceLessonsCoach prefix @@ -64,25 +105,115 @@ func LoadConfig() (*Config, error) { v.BindEnv("server.port", "DLC_SERVER_PORT") v.BindEnv("shutdown.timeout", "DLC_SHUTDOWN_TIMEOUT") v.BindEnv("logging.json", "DLC_LOGGING_JSON") - + v.BindEnv("logging.level", "DLC_LOGGING_LEVEL") + + // Telemetry environment variables + v.BindEnv("telemetry.enabled", "DLC_TELEMETRY_ENABLED") + v.BindEnv("telemetry.otlp_endpoint", "DLC_TELEMETRY_OTLP_ENDPOINT") + v.BindEnv("telemetry.service_name", "DLC_TELEMETRY_SERVICE_NAME") + v.BindEnv("telemetry.insecure", "DLC_TELEMETRY_INSECURE") + v.BindEnv("telemetry.sampler.type", "DLC_TELEMETRY_SAMPLER_TYPE") + v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO") + // Unmarshal into Config struct var config Config if err := v.Unmarshal(&config); err != nil { log.Error().Err(err).Msg("Failed to unmarshal config") return nil, fmt.Errorf("config unmarshal error: %w", err) } - + + // Configure log output format (JSON or console) first + if config.Logging.JSON { + log.Logger = log.Output(os.Stderr) + } else { + consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr} + log.Logger = log.Output(consoleWriter) + } + + // Setup logging based on configuration + config.SetupLogging() + log.Info(). Str("host", config.Server.Host). Int("port", config.Server.Port). Dur("shutdown_timeout", config.Shutdown.Timeout). Bool("logging_json", config.Logging.JSON). + Str("logging_level", config.Logging.Level). + Bool("telemetry_enabled", config.Telemetry.Enabled). + Str("telemetry_service", config.Telemetry.ServiceName). Msg("Configuration loaded") - + return &config, nil } // GetServerAddress returns the formatted server address (host:port) func (c *Config) GetServerAddress() string { return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port) -} \ No newline at end of file +} + +// GetTelemetryEnabled returns whether telemetry is enabled +func (c *Config) GetTelemetryEnabled() bool { + return c.Telemetry.Enabled +} + +// GetOTLPEndpoint returns the OTLP endpoint for telemetry +func (c *Config) GetOTLPEndpoint() string { + return c.Telemetry.OTLPEndpoint +} + +// GetServiceName returns the service name for telemetry +func (c *Config) GetServiceName() string { + return c.Telemetry.ServiceName +} + +// GetTelemetryInsecure returns whether to use insecure connection +func (c *Config) GetTelemetryInsecure() bool { + return c.Telemetry.Insecure +} + +// GetSamplerType returns the sampler type +func (c *Config) GetSamplerType() string { + return c.Telemetry.Sampler.Type +} + +// GetSamplerRatio returns the sampler ratio +func (c *Config) GetSamplerRatio() float64 { + return c.Telemetry.Sampler.Ratio +} + +// GetLogLevel returns the logging level +func (c *Config) GetLogLevel() string { + return c.Logging.Level +} + +// SetupLogging configures zerolog based on the configuration +func (c *Config) SetupLogging() { + // Parse log level + level := parseLogLevel(c.GetLogLevel()) + zerolog.SetGlobalLevel(level) + + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix +} + +// parseLogLevel converts a string log level to zerolog.Level +func parseLogLevel(level string) zerolog.Level { + switch strings.ToLower(level) { + case "trace": + return zerolog.TraceLevel + case "debug": + return zerolog.DebugLevel + case "info": + return zerolog.InfoLevel + case "warn", "warning": + return zerolog.WarnLevel + case "error": + return zerolog.ErrorLevel + case "fatal": + return zerolog.FatalLevel + case "panic": + return zerolog.PanicLevel + default: + log.Warn().Str("level", level).Msg("Unknown log level, defaulting to trace") + return zerolog.TraceLevel + } +} diff --git a/pkg/greet/api_v1.go b/pkg/greet/api_v1.go index a8101a7..cecbd1f 100644 --- a/pkg/greet/api_v1.go +++ b/pkg/greet/api_v1.go @@ -45,4 +45,4 @@ func (h *apiV1GreetHandler) handleGreetPath(w http.ResponseWriter, r *http.Reque func (h *apiV1GreetHandler) writeJSONResponse(w http.ResponseWriter, message string) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"message": message}) -} \ No newline at end of file +} diff --git a/pkg/greet/greet.go b/pkg/greet/greet.go index c333fb3..4b68b80 100644 --- a/pkg/greet/greet.go +++ b/pkg/greet/greet.go @@ -16,7 +16,7 @@ func NewService() *Service { // Implements the Greeter interface. func (s *Service) Greet(ctx context.Context, name string) string { log.Trace().Ctx(ctx).Str("name", name).Msg("Greet function called") - + if name == "" { return "Hello world!" } diff --git a/pkg/greet/greet_test.go b/pkg/greet/greet_test.go index 00d1e09..5f8ff9f 100644 --- a/pkg/greet/greet_test.go +++ b/pkg/greet/greet_test.go @@ -25,4 +25,4 @@ func TestService_Greet(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/pkg/server/server.go b/pkg/server/server.go index f315b4a..75861fd 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -2,10 +2,17 @@ package server import ( "context" + "net" "net/http" + "os/signal" + "syscall" + "time" "DanceLessonsCoach/pkg/config" "DanceLessonsCoach/pkg/greet" + "DanceLessonsCoach/pkg/telemetry" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + sdktrace "go.opentelemetry.io/otel/sdk/trace" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -15,12 +22,17 @@ import ( type Server struct { router *chi.Mux readyCtx context.Context + withOTEL bool + config *config.Config + tracerProvider *sdktrace.TracerProvider } func NewServer(cfg *config.Config, readyCtx context.Context) *Server { s := &Server{ - router: chi.NewRouter(), - readyCtx: readyCtx, + router: chi.NewRouter(), + readyCtx: readyCtx, + withOTEL: cfg.GetTelemetryEnabled(), + config: cfg, } s.setupRoutes() return s @@ -41,7 +53,7 @@ func (s *Server) setupRoutes() { // API routes s.router.Route("/api/v1", func(r chi.Router) { - r.Use(s.apiMiddlewares()...) + r.Use(s.getAllMiddlewares()...) s.registerApiV1Routes(r) }) } @@ -54,11 +66,20 @@ func (s *Server) registerApiV1Routes(r chi.Router) { }) } -func (s *Server) apiMiddlewares() []func(http.Handler) http.Handler { - return []func(http.Handler) http.Handler{ +// getAllMiddlewares returns all middleware including OpenTelemetry if enabled +func (s *Server) getAllMiddlewares() []func(http.Handler) http.Handler { + middlewares := []func(http.Handler) http.Handler{ middleware.StripSlashes, middleware.Recoverer, } + + if s.withOTEL { + middlewares = append(middlewares, func(next http.Handler) http.Handler { + return otelhttp.NewHandler(next, "") + }) + } + + return middlewares } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { @@ -68,7 +89,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) { log.Info().Msg("Readiness check requested") - + select { case <-s.readyCtx.Done(): log.Info().Msg("Readiness check: not ready (shutting down)") @@ -83,3 +104,99 @@ func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) { func (s *Server) Router() http.Handler { return s.router } + +// Run starts the HTTP server and handles graceful shutdown +func (s *Server) Run() error { + // Initialize OpenTelemetry if enabled + var err error + if s.withOTEL { + log.Info().Msg("Initializing OpenTelemetry tracing") + + telemetrySetup := &telemetry.Setup{ + ServiceName: s.config.GetServiceName(), + OTLPEndpoint: s.config.GetOTLPEndpoint(), + Insecure: s.config.GetTelemetryInsecure(), + SamplerType: s.config.GetSamplerType(), + SamplerRatio: s.config.GetSamplerRatio(), + } + + if s.tracerProvider, err = telemetrySetup.InitializeTracing(context.Background()); err != nil { + log.Error().Err(err).Msg("Failed to initialize OpenTelemetry, continuing without tracing") + s.withOTEL = false + } else { + log.Info().Msg("OpenTelemetry tracing initialized successfully") + } + } + + // Setup signal context for graceful shutdown + rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + // Create ongoing context for active requests + ongoingCtx, stopOngoingGracefully := context.WithCancel(context.Background()) + defer stopOngoingGracefully() + + // Create HTTP server + log.Info().Str("address", s.config.GetServerAddress()).Msg("Server running") + + srv := &http.Server{ + Addr: s.config.GetServerAddress(), + Handler: s.router, + BaseContext: func(_ net.Listener) context.Context { + return ongoingCtx + }, + } + + // Start the HTTP server in a separate goroutine + serverErrors := make(chan error, 1) + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + serverErrors <- err + } + close(serverErrors) + }() + + // Wait for signal + <-rootCtx.Done() + stop() + log.Info().Msg("Shutdown signal received") + + // Cancel readiness context to stop accepting new requests + if cancelReady, ok := s.readyCtx.(interface{ Cancel() }); ok { + cancelReady.Cancel() + } + log.Info().Msg("Readiness set to false, no longer accepting new requests") + + // Give time for readiness check to propagate + time.Sleep(1 * time.Second) + log.Info().Msg("Readiness check propagated, now waiting for ongoing requests to finish.") + + // Create shutdown context with timeout from config + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), s.config.Shutdown.Timeout) + defer shutdownCancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Error().Err(err).Msg("Server shutdown failed") + return err + } + log.Info().Msg("Server shutdown complete") + + // Shutdown OpenTelemetry tracer provider + if s.tracerProvider != nil { + if err := telemetry.Shutdown(context.Background(), s.tracerProvider); err != nil { + log.Error().Err(err).Msg("Failed to shutdown OpenTelemetry tracer provider") + } else { + log.Info().Msg("OpenTelemetry tracer provider shutdown complete") + } + } + + // Force log flush + time.Sleep(100 * time.Millisecond) + + // Return any server errors + if err, ok := <-serverErrors; ok { + return err + } + + return nil +} diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go new file mode 100644 index 0000000..4e6e2c4 --- /dev/null +++ b/pkg/telemetry/telemetry.go @@ -0,0 +1,100 @@ +// Package telemetry provides OpenTelemetry instrumentation for the DanceLessonsCoach application +package telemetry + +import ( + "context" + "log" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" + "go.opentelemetry.io/otel/trace" +) + +// Setup initializes OpenTelemetry tracing with the given configuration +type Setup struct { + ServiceName string + OTLPEndpoint string + Insecure bool + SamplerType string + SamplerRatio float64 +} + +// InitializeTracing sets up OpenTelemetry tracing provider +func (s *Setup) InitializeTracing(ctx context.Context) (*sdktrace.TracerProvider, error) { + // Create OTLP gRPC exporter + exporter, err := otlptracegrpc.New(ctx, + otlptracegrpc.WithEndpoint(s.OTLPEndpoint), + otlptracegrpc.WithInsecure(), + ) + if err != nil { + return nil, err + } + + // Create resource with service name + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName(s.ServiceName), + ), + ) + if err != nil { + return nil, err + } + + // Create sampler based on configuration + sampler := s.getSampler() + + // Create trace provider + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + sdktrace.WithSampler(sampler), + ) + + // Set global tracer provider and propagator + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + return tp, nil +} + +// Shutdown performs cleanup of the tracer provider +func Shutdown(ctx context.Context, tp *sdktrace.TracerProvider) error { + if tp == nil { + return nil + } + return tp.Shutdown(ctx) +} + +// getSampler returns the appropriate sampler based on configuration +func (s *Setup) getSampler() sdktrace.Sampler { + switch s.SamplerType { + case "always_on": + return sdktrace.AlwaysSample() + case "always_off": + return sdktrace.NeverSample() + case "traceidratio": + return sdktrace.TraceIDRatioBased(s.SamplerRatio) + case "parentbased_always_on": + return sdktrace.ParentBased(sdktrace.AlwaysSample()) + case "parentbased_always_off": + return sdktrace.ParentBased(sdktrace.NeverSample()) + case "parentbased_traceidratio": + return sdktrace.ParentBased(sdktrace.TraceIDRatioBased(s.SamplerRatio)) + default: + log.Printf("Unknown sampler type: %s, defaulting to always_on", s.SamplerType) + return sdktrace.AlwaysSample() + } +} + +// GetTracer returns a named tracer from the global provider +// Returns a no-op tracer if OpenTelemetry is not initialized +func GetTracer(name string) trace.Tracer { + return otel.Tracer(name) +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..719f7c9 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# DanceLessonsCoach Build Script +# Builds binaries into the bin/ directory + +set -e + +echo "🔨 Building DanceLessonsCoach binaries..." + +# Create bin directory if it doesn't exist +mkdir -p bin + +# Build server binary +echo "📦 Building server..." +go build -o bin/server ./cmd/server + +# Build greet CLI binary +echo "📦 Building greet CLI..." +go build -o bin/greet ./cmd/greet + +echo "✅ Build complete!" +echo " Server binary: ./bin/server" +echo " Greet binary: ./bin/greet" +echo "" +echo "💡 To run the server: ./bin/server" +echo "💡 To use the greet CLI: ./bin/greet [name]" diff --git a/scripts/test-opentelemetry.sh b/scripts/test-opentelemetry.sh new file mode 100755 index 0000000..a6abd15 --- /dev/null +++ b/scripts/test-opentelemetry.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# DanceLessonsCoach OpenTelemetry Test Script +# This script tests OpenTelemetry integration with Jaeger + +set -e + +echo -e "\033[1;34m=== DanceLessonsCoach OpenTelemetry Test ===\033[0m" +echo "" + +# Configuration +PROJECT_DIR="/Users/gabrielradureau/Work/Vibe/DanceLessonsCoach" +SERVER_CMD="./scripts/start-server.sh" +LOG_FILE="server.log" +PID_FILE="server.pid" + +cd "$PROJECT_DIR" + +# Clean up any existing server +echo "Cleaning up any existing server..." +if [ -f "$PID_FILE" ]; then + echo "Found existing PID file, stopping previous server..." + $SERVER_CMD stop > /dev/null 2>&1 || true + rm -f "$PID_FILE" "$LOG_FILE" +fi + +# Kill any processes on port 8080 +pkill -9 -f "go run" > /dev/null 2>&1 || true +lsof -ti :8080 | xargs -I {} kill -9 {} > /dev/null 2>&1 || true +lsof -ti :4317 | xargs -I {} kill -9 {} > /dev/null 2>&1 || true +sleep 1 + +echo "Starting Jaeger in Docker..." +# Start Jaeger container if not running +if ! docker ps | grep -q "jaegertracing/all-in-one"; then + docker run -d --name jaeger \ + -e COLLECTOR_OTLP_ENABLED=true \ + -p 16686:16686 \ + -p 4317:4317 \ + -p 4318:4318 \ + jaegertracing/all-in-one:latest + echo "Jaeger container started" + sleep 5 +else + echo "Jaeger container already running" +fi + +echo "Starting server with OpenTelemetry enabled..." +DLC_TELEMETRY_ENABLED=true DLC_TELEMETRY_OTLP_ENDPOINT="localhost:4317" DLC_TELEMETRY_INSECURE=true \ + DLC_TELEMETRY_SERVICE_NAME="DanceLessonsCoach" $SERVER_CMD start +sleep 3 + +echo "Testing API endpoints..." + +# Test health endpoint +echo "Testing /api/health:" +HEALTH_RESPONSE=$(curl -s http://localhost:8080/api/health) +echo "Response: $HEALTH_RESPONSE" + +# Test greet endpoint +echo "Testing /api/v1/greet/:" +GREET_RESPONSE=$(curl -s http://localhost:8080/api/v1/greet/) +echo "Response: $GREET_RESPONSE" + +# Test greet with name +echo "Testing /api/v1/greet/TestUser:" +GREET_NAME_RESPONSE=$(curl -s http://localhost:8080/api/v1/greet/TestUser) +echo "Response: $GREET_NAME_RESPONSE" + +echo "" +echo "Stopping server..." +$SERVER_CMD stop +sleep 2 + +echo "" +echo -e "\033[0;32m✅ OpenTelemetry Test Complete!\033[0m" +echo "" +echo "To view traces in Jaeger:" +echo "1. Open http://localhost:16686 in your browser" +echo "2. Select 'DanceLessonsCoach' service" +echo "3. Click 'Find Traces' button" +echo "" +echo "You should see traces for:" +echo "- /api/health requests" +echo "- /api/v1/greet/ requests" +echo "- /api/v1/greet/TestUser requests" +echo "" + +# Clean up +rm -f "$PID_FILE" "$LOG_FILE" + +exit 0 \ No newline at end of file