Add OpenTelemetry instrumentation with middleware-only approach

This commit is contained in:
Mistral Vibe
2026-04-04 12:19:48 +02:00
committed by Gabriel Radureau
parent c38d7c6d76
commit 36f0b79b90
13 changed files with 452 additions and 46 deletions

View File

@@ -11,16 +11,41 @@ import (
// 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"`
}
// 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
@@ -28,13 +53,21 @@ type Config struct {
// To specify a custom config file path, set DLC_CONFIG_FILE environment variable
func LoadConfig() (*Config, error) {
v := viper.New()
// 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)
// 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 +78,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 +89,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 +97,65 @@ 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")
// 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)
}
log.Info().
Str("host", config.Server.Host).
Int("port", config.Server.Port).
Dur("shutdown_timeout", config.Shutdown.Timeout).
Bool("logging_json", config.Logging.JSON).
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)
}
}
// 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
}

View File

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

View File

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

View File

@@ -25,4 +25,4 @@ func TestService_Greet(t *testing.T) {
}
})
}
}
}

View File

@@ -6,6 +6,7 @@ import (
"DanceLessonsCoach/pkg/config"
"DanceLessonsCoach/pkg/greet"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -13,14 +14,16 @@ import (
)
type Server struct {
router *chi.Mux
readyCtx context.Context
router *chi.Mux
readyCtx context.Context
withOTEL bool
}
func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
s := &Server{
router: chi.NewRouter(),
readyCtx: readyCtx,
router: chi.NewRouter(),
readyCtx: readyCtx,
withOTEL: cfg.GetTelemetryEnabled(),
}
s.setupRoutes()
return s
@@ -41,7 +44,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 +57,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 +80,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)")

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