Merge pull request 'feature/opentelemetry' (#1) from feature/opentelemetry into main

Reviewed-on: arcodange/DanceLessonsCoach#1
This commit is contained in:
2026-04-04 13:26:17 +02:00
15 changed files with 632 additions and 137 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
*.exe
*.test
*.out
bin/
# Dependency directories
vendor/

View File

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

View File

@@ -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()
if err := server.Run(); err != nil {
log.Fatal().Err(err).Msg("Server failed")
}
}

View File

@@ -22,9 +22,45 @@ logging:
# 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
# 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

23
go.mod
View File

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

45
go.sum
View File

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

View File

@@ -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,11 +57,24 @@ 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 != "" {
@@ -64,6 +105,15 @@ 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
@@ -72,11 +122,25 @@ func LoadConfig() (*Config, error) {
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
@@ -86,3 +150,70 @@ func LoadConfig() (*Config, error) {
func (c *Config) GetServerAddress() string {
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
}
// 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
}
}

View File

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

100
pkg/telemetry/telemetry.go Normal file
View File

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

26
scripts/build.sh Executable file
View File

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

92
scripts/test-opentelemetry.sh Executable file
View File

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