From 420792b83049d1b065427791814fc78b66ae9d96 Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Fri, 11 Jul 2025 21:31:08 +0200 Subject: [PATCH 01/17] docs: update documentation for service package, streamline features and examples --- .github/atomicgo/custom_readme | 0 README.md | 478 ++++++++------------------------- doc.go | 188 ++----------- 3 files changed, 124 insertions(+), 542 deletions(-) create mode 100644 .github/atomicgo/custom_readme diff --git a/.github/atomicgo/custom_readme b/.github/atomicgo/custom_readme new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 2b9017e..e67f9df 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,3 @@ - -

AtomicGo | service

@@ -76,41 +59,34 @@

- - - - -# service - -```go -import "atomicgo.dev/service" -``` - -Package service provides a lightweight, production\-ready HTTP service framework for Go applications. - -The service framework is designed to be Kubernetes\-ready and follows best practices for highly available microservices. It includes built\-in support for graceful shutdown, Prometheus metrics, structured logging, middleware, and environment\-based configuration. - -\#\# Features +--- -\- \*\*HTTP Server\*\*: Configurable HTTP server with timeouts and graceful shutdown \- \*\*Metrics\*\*: Built\-in Prometheus metrics collection with automatic request tracking \- \*\*Logging\*\*: Structured logging with slog integration and context\-aware loggers \- \*\*Middleware\*\*: Extensible middleware system with built\-in recovery and logging \- \*\*Configuration\*\*: Environment\-based configuration with sensible defaults \- \*\*Graceful Shutdown\*\*: Signal handling with configurable shutdown hooks \- \*\*Health Checks\*\*: Built\-in health check endpoints \- \*\*Kubernetes Ready\*\*: Designed for containerized deployments +A lightweight, production-ready HTTP service framework for Go applications designed to be Kubernetes-ready and follow best practices for highly available microservices. -\#\# Quick Start +## Features -\`\`\`go package main +- **HTTP Server**: Configurable HTTP server with timeouts and graceful shutdown +- **Metrics**: Built-in Prometheus metrics collection with automatic request tracking +- **Logging**: Structured logging with slog integration and context-aware loggers +- **Middleware**: Extensible middleware system with built-in recovery and logging +- **Configuration**: Environment-based configuration with sensible defaults +- **Graceful Shutdown**: Signal handling with configurable shutdown hooks +- **Health Checks**: Built-in health check endpoints +- **Kubernetes Ready**: Designed for containerized deployments -import \( +## Quick Start -``` -"log/slog" -"net/http" -"os" +```go +package main -"atomicgo.dev/service" -``` +import ( + "log/slog" + "net/http" + "os" -\) + "atomicgo.dev/service" +) -``` func main() { // Create service with default configuration svc := service.New("my-service", nil) @@ -129,33 +105,42 @@ func main() { } ``` -\`\`\` - -\#\# Configuration +## Configuration The framework supports configuration via environment variables with sensible defaults: -\- \`ADDR\`: HTTP server address \(default: ":8080"\) \- \`METRICS\_ADDR\`: Metrics server address \(default: ":9090"\) \- \`METRICS\_PATH\`: Metrics endpoint path \(default: "/metrics"\) \- \`READ\_TIMEOUT\`: HTTP read timeout \(default: "10s"\) \- \`WRITE\_TIMEOUT\`: HTTP write timeout \(default: "10s"\) \- \`IDLE\_TIMEOUT\`: HTTP idle timeout \(default: "120s"\) \- \`SHUTDOWN\_TIMEOUT\`: Graceful shutdown timeout \(default: "30s"\) +| Variable | Default | Description | +|----------|---------|-------------| +| `ADDR` | `:8080` | HTTP server address | +| `METRICS_ADDR` | `:9090` | Metrics server address | +| `METRICS_PATH` | `/metrics` | Metrics endpoint path | +| `READ_TIMEOUT` | `10s` | HTTP read timeout | +| `WRITE_TIMEOUT` | `10s` | HTTP write timeout | +| `IDLE_TIMEOUT` | `120s` | HTTP idle timeout | +| `SHUTDOWN_TIMEOUT` | `30s` | Graceful shutdown timeout | -\`\`\`go // Load configuration from environment config, err := service.LoadFromEnv\(\) - -``` +```go +// Load configuration from environment +config, err := service.LoadFromEnv() if err != nil { log.Fatal(err) } -``` - -// Create service with custom configuration svc := service.New\("my\-service", config\) \`\`\` -\#\# Middleware +// Create service with custom configuration +svc := service.New("my-service", config) +``` -The framework includes several built\-in middleware: +## Middleware -\- \*\*LoggerMiddleware\*\*: Injects logger into request context \- \*\*RecoveryMiddleware\*\*: Recovers from panics and logs errors \- \*\*RequestLoggingMiddleware\*\*: Logs incoming requests \- \*\*MetricsMiddleware\*\*: Tracks HTTP metrics for Prometheus +The framework includes several built-in middleware: -\`\`\`go // Add custom middleware +- **LoggerMiddleware**: Injects logger into request context +- **RecoveryMiddleware**: Recovers from panics and logs errors +- **RequestLoggingMiddleware**: Logs incoming requests +- **MetricsMiddleware**: Tracks HTTP metrics for Prometheus -``` +```go +// Add custom middleware svc.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Custom", "value") @@ -164,19 +149,18 @@ svc.Use(func(next http.Handler) http.Handler { }) ``` -\`\`\` - -\#\# Metrics +## Metrics The framework automatically collects Prometheus metrics: -\- \`\{service\_name\}\_http\_requests\_total\`: Total HTTP requests \- \`\{service\_name\}\_http\_request\_duration\_seconds\`: Request duration \- \`\{service\_name\}\_http\_requests\_in\_flight\`: In\-flight requests +- `{service_name}_http_requests_total`: Total HTTP requests +- `{service_name}_http_request_duration_seconds`: Request duration +- `{service_name}_http_requests_in_flight`: In-flight requests -Metrics are available at \`:9090/metrics\` by default. +Metrics are available at `:9090/metrics` by default. -\`\`\`go // Access metrics in handlers - -``` +```go +// Access metrics in handlers func myHandler(w http.ResponseWriter, r *http.Request) { metrics := service.GetMetrics(r) if metrics != nil { @@ -185,354 +169,110 @@ func myHandler(w http.ResponseWriter, r *http.Request) { } ``` -\`\`\` - -\#\# Graceful Shutdown +## Graceful Shutdown The framework supports graceful shutdown with signal handling and custom hooks: -\`\`\`go // Add shutdown hooks - -``` +```go +// Add shutdown hooks svc.AddShutdownHook(func() error { // Cleanup resources return nil }) -``` - -// Start with graceful shutdown svc.StartWithGracefulShutdown\(\) \`\`\` -\#\# Logging +// Start with graceful shutdown +svc.StartWithGracefulShutdown() +``` -The framework uses structured logging with slog and provides context\-aware loggers: +## Logging -\`\`\`go +The framework uses structured logging with slog and provides context-aware loggers: -``` +```go func myHandler(w http.ResponseWriter, r *http.Request) { logger := service.GetLogger(r) logger.Info("request processed", "path", r.URL.Path) } ``` -\`\`\` - -\#\# Health Checks +## Health Checks Health check endpoints are automatically available: -\- \`:9090/health\`: Basic health check \- \`:9090/metrics\`: Prometheus metrics +- `:9090/health`: Basic health check +- `:9090/metrics`: Prometheus metrics -\#\# Kubernetes Deployment +## Kubernetes Deployment The framework is designed for Kubernetes deployments with: -\- Graceful shutdown handling SIGTERM \- Health check endpoints for liveness/readiness probes \- Prometheus metrics for monitoring \- Configurable resource limits via environment variables - -\#\# Examples - -See the \`\_example/\` directory for complete working examples demonstrating: - -\- Basic service setup \- Custom middleware \- Environment configuration \- Graceful shutdown \- Metrics integration - -The framework is designed to be lightweight while providing all essential features for production\-ready microservices. - -## Index - -- [func GetLogger\(r \*http.Request\) \*slog.Logger](<#GetLogger>) -- [func IncCounter\(r \*http.Request, name string, labels ...string\)](<#IncCounter>) -- [func ObserveHistogram\(r \*http.Request, name string, value float64, labels ...string\)](<#ObserveHistogram>) -- [type Config](<#Config>) - - [func DefaultConfig\(\) \*Config](<#DefaultConfig>) - - [func LoadFromEnv\(\) \(\*Config, error\)](<#LoadFromEnv>) - - [func \(c \*Config\) AddShutdownHook\(hook func\(\) error\)](<#Config.AddShutdownHook>) -- [type ContextKey](<#ContextKey>) -- [type MetricsCollector](<#MetricsCollector>) - - [func GetMetrics\(r \*http.Request\) \*MetricsCollector](<#GetMetrics>) - - [func NewMetricsCollector\(serviceName string\) \*MetricsCollector](<#NewMetricsCollector>) -- [type Middleware](<#Middleware>) - - [func LoggerMiddleware\(logger \*slog.Logger\) Middleware](<#LoggerMiddleware>) - - [func MetricsMiddleware\(metrics \*MetricsCollector\) Middleware](<#MetricsMiddleware>) - - [func RecoveryMiddleware\(logger \*slog.Logger\) Middleware](<#RecoveryMiddleware>) - - [func RequestLoggingMiddleware\(logger \*slog.Logger\) Middleware](<#RequestLoggingMiddleware>) -- [type Service](<#Service>) - - [func New\(name string, config \*Config\) \*Service](<#New>) - - [func \(s \*Service\) AddShutdownHook\(hook func\(\) error\)](<#Service.AddShutdownHook>) - - [func \(s \*Service\) Handle\(pattern string, handler http.Handler\)](<#Service.Handle>) - - [func \(s \*Service\) HandleFunc\(pattern string, handler http.HandlerFunc\)](<#Service.HandleFunc>) - - [func \(s \*Service\) Start\(\) error](<#Service.Start>) - - [func \(s \*Service\) StartWithGracefulShutdown\(\) error](<#Service.StartWithGracefulShutdown>) - - [func \(s \*Service\) Stop\(\) error](<#Service.Stop>) - - [func \(s \*Service\) Use\(middleware Middleware\)](<#Service.Use>) - - - -## func [GetLogger]() - -```go -func GetLogger(r *http.Request) *slog.Logger -``` - -GetLogger retrieves the logger from the request context - - -## func [IncCounter]() - -```go -func IncCounter(r *http.Request, name string, labels ...string) -``` - -IncCounter increments a counter metric - - -## func [ObserveHistogram]() - -```go -func ObserveHistogram(r *http.Request, name string, value float64, labels ...string) -``` - -ObserveHistogram observes a histogram metric - - -## type [Config]() +- Graceful shutdown handling SIGTERM +- Health check endpoints for liveness/readiness probes +- Prometheus metrics for monitoring +- Configurable resource limits via environment variables -Config holds all configuration for the service +## Examples -```go -type Config struct { - // HTTP Server configuration - Addr string `env:"ADDR" envDefault:":8080"` - ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"10s"` - WriteTimeout time.Duration `env:"WRITE_TIMEOUT" envDefault:"10s"` - IdleTimeout time.Duration `env:"IDLE_TIMEOUT" envDefault:"120s"` - - // Metrics server configuration - MetricsAddr string `env:"METRICS_ADDR" envDefault:":9090"` - MetricsPath string `env:"METRICS_PATH" envDefault:"/metrics"` +See the `_example/` directory for complete working examples demonstrating: - // Graceful shutdown configuration - ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"30s"` +- Basic service setup +- Custom middleware +- Environment configuration +- Graceful shutdown +- Metrics integration - // Logger configuration - Logger *slog.Logger `env:"-"` +### Running the Example - // Custom shutdown hooks - ShutdownHooks []func() error `env:"-"` -} +```bash +cd _example +go run main.go ``` - -### func [DefaultConfig]() - -```go -func DefaultConfig() *Config +Test the endpoints: +```bash +curl http://localhost:8080/ +curl http://localhost:8080/health +curl http://localhost:8080/metrics-demo +curl http://localhost:9090/metrics ``` -DefaultConfig creates a new config with default values - - -### func [LoadFromEnv]() +## Testing -```go -func LoadFromEnv() (*Config, error) -``` +The framework includes comprehensive tests and benchmarks: -LoadFromEnv loads configuration from environment variables +```bash +# Run all tests +go test -v ./... - -### func \(\*Config\) [AddShutdownHook]() +# Run benchmarks +go test -bench=. ./... -```go -func (c *Config) AddShutdownHook(hook func() error) -``` - -AddShutdownHook adds a function to be called during graceful shutdown - - -## type [ContextKey]() - -ContextKey is a custom type for context keys to avoid collisions - -```go -type ContextKey string +# Run with coverage +go test -cover ./... ``` - +## Best Practices -```go -const ( - // LoggerKey is the context key for the logger - LoggerKey ContextKey = "logger" - // MetricsKey is the context key for metrics - MetricsKey ContextKey = "metrics" -) -``` +1. **Always use graceful shutdown** for production deployments +2. **Configure appropriate timeouts** based on your application needs +3. **Add custom shutdown hooks** for resource cleanup +4. **Use structured logging** for better observability +5. **Monitor metrics** in production environments +6. **Set up health checks** for Kubernetes deployments - -## type [MetricsCollector]() +## Dependencies -MetricsCollector holds all the metrics for the service - -```go -type MetricsCollector struct { - // contains filtered or unexported fields -} -``` - - -### func [GetMetrics]() - -```go -func GetMetrics(r *http.Request) *MetricsCollector -``` - -GetMetrics retrieves the metrics collector from the request context - - -### func [NewMetricsCollector]() - -```go -func NewMetricsCollector(serviceName string) *MetricsCollector -``` - -NewMetricsCollector creates a new metrics collector - - -## type [Middleware]() - -Middleware represents a middleware function - -```go -type Middleware func(http.Handler) http.Handler -``` - - -### func [LoggerMiddleware]() - -```go -func LoggerMiddleware(logger *slog.Logger) Middleware -``` - -LoggerMiddleware injects the logger into the request context - - -### func [MetricsMiddleware]() - -```go -func MetricsMiddleware(metrics *MetricsCollector) Middleware -``` - -MetricsMiddleware creates middleware that records HTTP metrics - - -### func [RecoveryMiddleware]() - -```go -func RecoveryMiddleware(logger *slog.Logger) Middleware -``` - -RecoveryMiddleware recovers from panics and logs them - - -### func [RequestLoggingMiddleware]() - -```go -func RequestLoggingMiddleware(logger *slog.Logger) Middleware -``` - -RequestLoggingMiddleware logs incoming requests - - -## type [Service]() - -Service represents the main service instance - -```go -type Service struct { - Name string - Config *Config - Logger *slog.Logger - Metrics *MetricsCollector - // contains filtered or unexported fields -} -``` - - -### func [New]() - -```go -func New(name string, config *Config) *Service -``` - -New creates a new service instance - - -### func \(\*Service\) [AddShutdownHook]() - -```go -func (s *Service) AddShutdownHook(hook func() error) -``` - -AddShutdownHook adds a function to be called during graceful shutdown - - -### func \(\*Service\) [Handle]() - -```go -func (s *Service) Handle(pattern string, handler http.Handler) -``` - -Handle registers a handler for the given pattern - - -### func \(\*Service\) [HandleFunc]() - -```go -func (s *Service) HandleFunc(pattern string, handler http.HandlerFunc) -``` - -HandleFunc registers a handler function for the given pattern - - -### func \(\*Service\) [Start]() - -```go -func (s *Service) Start() error -``` - -Start starts the service and metrics server - - -### func \(\*Service\) [StartWithGracefulShutdown]() - -```go -func (s *Service) StartWithGracefulShutdown() error -``` - -StartWithGracefulShutdown starts the service with graceful shutdown handling - - -### func \(\*Service\) [Stop]() - -```go -func (s *Service) Stop() error -``` - -Stop stops the service gracefully - - -### func \(\*Service\) [Use]() - -```go -func (s *Service) Use(middleware Middleware) -``` +- `github.com/caarlos0/env/v11`: Environment variable parsing +- `github.com/prometheus/client_golang`: Prometheus metrics +- `log/slog`: Structured logging (Go 1.21+) -Use adds middleware to the service +## Contributing -Generated by [gomarkdoc]() +We welcome contributions! Please see our [Contributing Guide](https://github.com/atomicgo/atomicgo/blob/main/CONTRIBUTING.md) for details. +## License - +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. --- diff --git a/doc.go b/doc.go index ba93052..1c308a7 100644 --- a/doc.go +++ b/doc.go @@ -1,178 +1,20 @@ /* Package service provides a lightweight, production-ready HTTP service framework for Go applications. -The service framework is designed to be Kubernetes-ready and follows best practices for -highly available microservices. It includes built-in support for graceful shutdown, -Prometheus metrics, structured logging, middleware, and environment-based configuration. - -## Features - -- **HTTP Server**: Configurable HTTP server with timeouts and graceful shutdown -- **Metrics**: Built-in Prometheus metrics collection with automatic request tracking -- **Logging**: Structured logging with slog integration and context-aware loggers -- **Middleware**: Extensible middleware system with built-in recovery and logging -- **Configuration**: Environment-based configuration with sensible defaults -- **Graceful Shutdown**: Signal handling with configurable shutdown hooks -- **Health Checks**: Built-in health check endpoints -- **Kubernetes Ready**: Designed for containerized deployments - -## Quick Start - -```go -package main - -import ( - - "log/slog" - "net/http" - "os" - - "atomicgo.dev/service" - -) - - func main() { - // Create service with default configuration - svc := service.New("my-service", nil) - - // Register handlers - svc.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - logger.Info("Hello, World!") - w.Write([]byte("Hello, World!")) - }) - - // Start with graceful shutdown - if err := svc.StartWithGracefulShutdown(); err != nil { - os.Exit(1) - } - } - -``` - -## Configuration - -The framework supports configuration via environment variables with sensible defaults: - -- `ADDR`: HTTP server address (default: ":8080") -- `METRICS_ADDR`: Metrics server address (default: ":9090") -- `METRICS_PATH`: Metrics endpoint path (default: "/metrics") -- `READ_TIMEOUT`: HTTP read timeout (default: "10s") -- `WRITE_TIMEOUT`: HTTP write timeout (default: "10s") -- `IDLE_TIMEOUT`: HTTP idle timeout (default: "120s") -- `SHUTDOWN_TIMEOUT`: Graceful shutdown timeout (default: "30s") - -```go -// Load configuration from environment -config, err := service.LoadFromEnv() - - if err != nil { - log.Fatal(err) - } - -// Create service with custom configuration -svc := service.New("my-service", config) -``` - -## Middleware - -The framework includes several built-in middleware: - -- **LoggerMiddleware**: Injects logger into request context -- **RecoveryMiddleware**: Recovers from panics and logs errors -- **RequestLoggingMiddleware**: Logs incoming requests -- **MetricsMiddleware**: Tracks HTTP metrics for Prometheus - -```go -// Add custom middleware - - svc.Use(func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Custom", "value") - next.ServeHTTP(w, r) - }) - }) - -``` - -## Metrics - -The framework automatically collects Prometheus metrics: - -- `{service_name}_http_requests_total`: Total HTTP requests -- `{service_name}_http_request_duration_seconds`: Request duration -- `{service_name}_http_requests_in_flight`: In-flight requests - -Metrics are available at `:9090/metrics` by default. - -```go -// Access metrics in handlers - - func myHandler(w http.ResponseWriter, r *http.Request) { - metrics := service.GetMetrics(r) - if metrics != nil { - // Custom metric operations can be added here - } - } - -``` - -## Graceful Shutdown - -The framework supports graceful shutdown with signal handling and custom hooks: - -```go -// Add shutdown hooks - - svc.AddShutdownHook(func() error { - // Cleanup resources - return nil - }) - -// Start with graceful shutdown -svc.StartWithGracefulShutdown() -``` - -## Logging - -The framework uses structured logging with slog and provides context-aware loggers: - -```go - - func myHandler(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - logger.Info("request processed", "path", r.URL.Path) - } - -``` - -## Health Checks - -Health check endpoints are automatically available: - -- `:9090/health`: Basic health check -- `:9090/metrics`: Prometheus metrics - -## Kubernetes Deployment - -The framework is designed for Kubernetes deployments with: - -- Graceful shutdown handling SIGTERM -- Health check endpoints for liveness/readiness probes -- Prometheus metrics for monitoring -- Configurable resource limits via environment variables - -## Examples - -See the `_example/` directory for complete working examples demonstrating: - -- Basic service setup -- Custom middleware -- Environment configuration -- Graceful shutdown -- Metrics integration - -The framework is designed to be lightweight while providing all essential features -for production-ready microservices. +The framework is designed to be Kubernetes-ready and follows best practices for highly available +microservices. It includes built-in support for graceful shutdown, Prometheus metrics, structured +logging with slog, extensible middleware, and environment-based configuration. + +Key features: +- Configurable HTTP server with graceful shutdown +- Built-in Prometheus metrics collection +- Structured logging with context-aware loggers +- Extensible middleware system +- Environment-based configuration with sensible defaults +- Health check endpoints +- Kubernetes-ready design + +The framework is designed to be lightweight while providing all essential features for +production-ready microservices. */ package service From 1a51b3b2b775b03b0bbfda53a72a61dbfbf49b7f Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Fri, 11 Jul 2025 21:41:00 +0200 Subject: [PATCH 02/17] refactor: simplify service start process and update documentation for graceful shutdown --- README.md | 12 ++++++------ _example/main.go | 2 +- service.go | 48 +++++++++++++++++++++++++++++++++++---------- shutdown.go | 51 ------------------------------------------------ 4 files changed, 45 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index e67f9df..3b57326 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,8 @@ func main() { w.Write([]byte("Hello, World!")) }) - // Start with graceful shutdown - if err := svc.StartWithGracefulShutdown(); err != nil { + // Start service (includes graceful shutdown) + if err := svc.Start(); err != nil { os.Exit(1) } } @@ -171,7 +171,7 @@ func myHandler(w http.ResponseWriter, r *http.Request) { ## Graceful Shutdown -The framework supports graceful shutdown with signal handling and custom hooks: +The framework includes graceful shutdown by default with signal handling and custom hooks: ```go // Add shutdown hooks @@ -180,8 +180,8 @@ svc.AddShutdownHook(func() error { return nil }) -// Start with graceful shutdown -svc.StartWithGracefulShutdown() +// Start service (includes graceful shutdown) +svc.Start() ``` ## Logging @@ -253,7 +253,7 @@ go test -cover ./... ## Best Practices -1. **Always use graceful shutdown** for production deployments +1. **Graceful shutdown is enabled by default** - no additional configuration needed 2. **Configure appropriate timeouts** based on your application needs 3. **Add custom shutdown hooks** for resource cleanup 4. **Use structured logging** for better observability diff --git a/_example/main.go b/_example/main.go index c4fd65f..3d76eff 100644 --- a/_example/main.go +++ b/_example/main.go @@ -44,7 +44,7 @@ func main() { // Start service with graceful shutdown slog.Info("starting service with graceful shutdown support") - if err := svc.StartWithGracefulShutdown(); err != nil { + if err := svc.Start(); err != nil { svc.Logger.Error("failed to start service", "error", err) os.Exit(1) } diff --git a/service.go b/service.go index 484dc20..1962f88 100644 --- a/service.go +++ b/service.go @@ -3,6 +3,9 @@ package service import ( "log/slog" "net/http" + "os" + "os/signal" + "syscall" ) // Service represents the main service instance @@ -65,24 +68,49 @@ func (s *Service) Use(middleware Middleware) { s.middlewares = append(s.middlewares, middleware) } -// Start starts the service and metrics server +// Start starts the service with graceful shutdown handling func (s *Service) Start() error { - // Start metrics server in a goroutine + // Create a channel to receive OS signals + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + // Start the servers in goroutines + serverErrors := make(chan error, 2) + + // Start metrics server go func() { if err := s.startMetricsServer(); err != nil && err != http.ErrServerClosed { s.Logger.Error("metrics server error", "error", err) + serverErrors <- err } }() // Start main HTTP server - s.server = &http.Server{ - Addr: s.Config.Addr, - Handler: s.mux, - ReadTimeout: s.Config.ReadTimeout, - WriteTimeout: s.Config.WriteTimeout, - IdleTimeout: s.Config.IdleTimeout, + go func() { + s.server = &http.Server{ + Addr: s.Config.Addr, + Handler: s.mux, + ReadTimeout: s.Config.ReadTimeout, + WriteTimeout: s.Config.WriteTimeout, + IdleTimeout: s.Config.IdleTimeout, + } + + s.Logger.Info("starting service", "name", s.Name, "addr", s.Config.Addr) + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + s.Logger.Error("server error", "error", err) + serverErrors <- err + } + }() + + // Wait for either a signal or a server error + select { + case <-quit: + s.Logger.Info("received shutdown signal") + case err := <-serverErrors: + s.Logger.Error("server error, shutting down", "error", err) + return err } - s.Logger.Info("starting service", "name", s.Name, "addr", s.Config.Addr) - return s.server.ListenAndServe() + // Perform graceful shutdown + return s.gracefulShutdown() } diff --git a/shutdown.go b/shutdown.go index 4a44ef2..a99075d 100644 --- a/shutdown.go +++ b/shutdown.go @@ -2,59 +2,8 @@ package service import ( "context" - "net/http" - "os" - "os/signal" - "syscall" ) -// StartWithGracefulShutdown starts the service with graceful shutdown handling -func (s *Service) StartWithGracefulShutdown() error { - // Create a channel to receive OS signals - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - - // Start the servers in goroutines - serverErrors := make(chan error, 2) - - // Start metrics server - go func() { - if err := s.startMetricsServer(); err != nil && err != http.ErrServerClosed { - s.Logger.Error("metrics server error", "error", err) - serverErrors <- err - } - }() - - // Start main HTTP server - go func() { - s.server = &http.Server{ - Addr: s.Config.Addr, - Handler: s.mux, - ReadTimeout: s.Config.ReadTimeout, - WriteTimeout: s.Config.WriteTimeout, - IdleTimeout: s.Config.IdleTimeout, - } - - s.Logger.Info("starting service", "name", s.Name, "addr", s.Config.Addr) - if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - s.Logger.Error("server error", "error", err) - serverErrors <- err - } - }() - - // Wait for either a signal or a server error - select { - case <-quit: - s.Logger.Info("received shutdown signal") - case err := <-serverErrors: - s.Logger.Error("server error, shutting down", "error", err) - return err - } - - // Perform graceful shutdown - return s.gracefulShutdown() -} - // gracefulShutdown performs graceful shutdown of the service func (s *Service) gracefulShutdown() error { s.Logger.Info("starting graceful shutdown") From 7a5ee9b0cbeeae3ca774c77de9098210296f57c8 Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Fri, 11 Jul 2025 21:56:21 +0200 Subject: [PATCH 03/17] docs: update package description and README to clarify library purpose and features --- README.md | 68 ++++++++++++++++++++++++++++--------------------------- doc.go | 28 ++++++++++++----------- 2 files changed, 50 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 3b57326..5385b1c 100644 --- a/README.md +++ b/README.md @@ -61,21 +61,37 @@ --- -A lightweight, production-ready HTTP service framework for Go applications designed to be Kubernetes-ready and follow best practices for highly available microservices. +A minimal boilerplate wrapper for building production-ready Go HTTP services. This library reduces the boilerplate of writing production/enterprise-grade Go services to a minimum. + +**What this library provides:** +- Essential production features out of the box (metrics, health checks, graceful shutdown) +- Kubernetes and containerization boilerplate +- Lightweight wrapper around http.Server for high availability services + +**What this library does NOT provide:** +- HTTP framework or routing +- Business logic or application patterns +- Restrictions on how you write your HTTP handlers +- Opinionated application architecture + +Write HTTP handlers exactly as you prefer, using any patterns or frameworks you choose. This library handles the operational concerns while staying out of your application logic. ## Features -- **HTTP Server**: Configurable HTTP server with timeouts and graceful shutdown +- **Minimal Boilerplate**: Reduces production service setup to a few lines of code +- **HTTP Server Wrapper**: Lightweight wrapper around http.Server with production defaults - **Metrics**: Built-in Prometheus metrics collection with automatic request tracking - **Logging**: Structured logging with slog integration and context-aware loggers - **Middleware**: Extensible middleware system with built-in recovery and logging - **Configuration**: Environment-based configuration with sensible defaults - **Graceful Shutdown**: Signal handling with configurable shutdown hooks -- **Health Checks**: Built-in health check endpoints -- **Kubernetes Ready**: Designed for containerized deployments +- **Health Checks**: Built-in health check endpoints for Kubernetes +- **Framework Agnostic**: Works with any HTTP patterns or frameworks you prefer (as long as the framework supports the standard `http` package) ## Quick Start +Minimal boilerplate to get a production-ready service with metrics, health checks, and graceful shutdown: + ```go package main @@ -91,20 +107,27 @@ func main() { // Create service with default configuration svc := service.New("my-service", nil) - // Register handlers + // Write HTTP handlers exactly as you prefer svc.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) + logger := service.GetLogger(r) // Easy access to the logger logger.Info("Hello, World!") w.Write([]byte("Hello, World!")) }) - // Start service (includes graceful shutdown) + // Start service (includes graceful shutdown, metrics, health checks) if err := svc.Start(); err != nil { os.Exit(1) } } ``` +That's it! Your service now has: +- Prometheus metrics at `:9090/metrics` +- Health checks at `:9090/health` +- Graceful shutdown handling +- Structured logging +- Kubernetes-ready configuration + ## Configuration The framework supports configuration via environment variables with sensible defaults: @@ -204,12 +227,13 @@ Health check endpoints are automatically available: ## Kubernetes Deployment -The framework is designed for Kubernetes deployments with: +The library provides all the boilerplate needed for Kubernetes deployments: - Graceful shutdown handling SIGTERM - Health check endpoints for liveness/readiness probes - Prometheus metrics for monitoring - Configurable resource limits via environment variables +- No additional Kubernetes-specific code required ## Examples @@ -236,35 +260,13 @@ curl http://localhost:8080/metrics-demo curl http://localhost:9090/metrics ``` -## Testing - -The framework includes comprehensive tests and benchmarks: - -```bash -# Run all tests -go test -v ./... - -# Run benchmarks -go test -bench=. ./... - -# Run with coverage -go test -cover ./... -``` - ## Best Practices -1. **Graceful shutdown is enabled by default** - no additional configuration needed -2. **Configure appropriate timeouts** based on your application needs -3. **Add custom shutdown hooks** for resource cleanup +1. **Minimal setup** - Start with default configuration and customize only what you need +2. **Write HTTP handlers naturally** - Use any patterns or frameworks you prefer +3. **Add custom shutdown hooks** for resource cleanup when needed 4. **Use structured logging** for better observability 5. **Monitor metrics** in production environments -6. **Set up health checks** for Kubernetes deployments - -## Dependencies - -- `github.com/caarlos0/env/v11`: Environment variable parsing -- `github.com/prometheus/client_golang`: Prometheus metrics -- `log/slog`: Structured logging (Go 1.21+) ## Contributing diff --git a/doc.go b/doc.go index 1c308a7..53b8376 100644 --- a/doc.go +++ b/doc.go @@ -1,20 +1,22 @@ /* -Package service provides a lightweight, production-ready HTTP service framework for Go applications. +Package service provides a minimal boilerplate wrapper for building production-ready Go HTTP services. -The framework is designed to be Kubernetes-ready and follows best practices for highly available -microservices. It includes built-in support for graceful shutdown, Prometheus metrics, structured -logging with slog, extensible middleware, and environment-based configuration. +This library reduces the boilerplate of writing production/enterprise-grade Go services to a minimum. +It does NOT provide an HTTP framework, business logic, or impose restrictions on web frameworks. +Instead, it's a lightweight wrapper around http.Server that provides essential production features +out of the box for high availability services. -Key features: -- Configurable HTTP server with graceful shutdown -- Built-in Prometheus metrics collection -- Structured logging with context-aware loggers -- Extensible middleware system +Key benefits: +- Minimal boilerplate for Kubernetes and containerized production deployments +- Built-in Prometheus metrics collection and health checks +- Graceful shutdown with signal handling +- Structured logging with slog integration - Environment-based configuration with sensible defaults -- Health check endpoints -- Kubernetes-ready design +- Extensible middleware system +- No restrictions on HTTP frameworks -The framework is designed to be lightweight while providing all essential features for -production-ready microservices. +The framework is designed to be a thin layer that handles the operational concerns of production +services while letting you write HTTP handlers exactly as you prefer, using any patterns or +frameworks you choose. */ package service From 43cff31fc9ce446ec8309f91b30e2da2deb29f67 Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Fri, 11 Jul 2025 22:15:13 +0200 Subject: [PATCH 04/17] feat: add comprehensive health check endpoints and integrate health-go library - Introduced health check configuration in `Config` struct with paths for health, readiness, and liveness checks. - Updated `Service` to include a health checker and middleware for health checks. - Enhanced metrics server to support comprehensive health check endpoints. - Updated README with new health check features and usage examples. --- README.md | 121 +++++++++++++++- _example/main.go | 114 +++++++++++++-- config.go | 12 ++ go.mod | 3 + go.sum | 8 ++ health.go | 115 +++++++++++++++ health_test.go | 361 +++++++++++++++++++++++++++++++++++++++++++++++ metrics.go | 30 +++- middleware.go | 16 +++ service.go | 49 +++++-- 10 files changed, 795 insertions(+), 34 deletions(-) create mode 100644 health.go create mode 100644 health_test.go diff --git a/README.md b/README.md index 5385b1c..6478b67 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,9 @@ func main() { That's it! Your service now has: - Prometheus metrics at `:9090/metrics` -- Health checks at `:9090/health` +- Comprehensive health checks at `:9090/health` +- Kubernetes readiness probe at `:9090/ready` +- Kubernetes liveness probe at `:9090/live` - Graceful shutdown handling - Structured logging - Kubernetes-ready configuration @@ -137,6 +139,10 @@ The framework supports configuration via environment variables with sensible def | `ADDR` | `:8080` | HTTP server address | | `METRICS_ADDR` | `:9090` | Metrics server address | | `METRICS_PATH` | `/metrics` | Metrics endpoint path | +| `HEALTH_PATH` | `/health` | Health check endpoint path | +| `READINESS_PATH` | `/ready` | Readiness probe endpoint path | +| `LIVENESS_PATH` | `/live` | Liveness probe endpoint path | +| `SERVICE_VERSION` | `v1.0.0` | Service version for health checks | | `READ_TIMEOUT` | `10s` | HTTP read timeout | | `WRITE_TIMEOUT` | `10s` | HTTP write timeout | | `IDLE_TIMEOUT` | `120s` | HTTP idle timeout | @@ -220,11 +226,114 @@ func myHandler(w http.ResponseWriter, r *http.Request) { ## Health Checks -Health check endpoints are automatically available: +The framework integrates with [HelloFresh's health-go library](https://github.com/hellofresh/health-go) to provide comprehensive health checking capabilities. -- `:9090/health`: Basic health check +### Built-in Health Endpoints + +Health check endpoints are automatically available on the metrics server: + +- `:9090/health`: Comprehensive health check with detailed status information +- `:9090/ready`: Kubernetes readiness probe endpoint +- `:9090/live`: Kubernetes liveness probe endpoint - `:9090/metrics`: Prometheus metrics +### Adding Custom Health Checks + +You can register custom health checks using the `RegisterHealthCheck` method: + +```go +func main() { + svc := service.New("my-service", nil) + + // Register a database health check + svc.RegisterHealthCheck(health.Config{ + Name: "database", + Timeout: time.Second * 5, + SkipOnErr: false, // This check is critical + Check: func(ctx context.Context) error { + // Your database health check logic here + return db.PingContext(ctx) + }, + }) + + // Register a Redis health check + svc.RegisterHealthCheck(health.Config{ + Name: "redis", + Timeout: time.Second * 3, + SkipOnErr: true, // This check is optional + Check: func(ctx context.Context) error { + // Your Redis health check logic here + return redisClient.Ping(ctx).Err() + }, + }) + + svc.Start() +} +``` + +### Using Built-in Health Checkers + +The health-go library provides several built-in health checkers for common services: + +```go +import ( + "github.com/hellofresh/health-go/v5" + healthMysql "github.com/hellofresh/health-go/v5/checks/mysql" + healthRedis "github.com/hellofresh/health-go/v5/checks/redis" +) + +func main() { + svc := service.New("my-service", nil) + + // MySQL health check + svc.RegisterHealthCheck(health.Config{ + Name: "mysql", + Timeout: time.Second * 5, + Check: healthMysql.New(healthMysql.Config{ + DSN: "user:password@tcp(localhost:3306)/dbname", + }), + }) + + // Redis health check + svc.RegisterHealthCheck(health.Config{ + Name: "redis", + Timeout: time.Second * 3, + Check: healthRedis.New(healthRedis.Config{ + Addr: "localhost:6379", + }), + }) + + svc.Start() +} +``` + +### Health Check Configuration + +You can configure health check endpoints using environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `HEALTH_PATH` | `/health` | Main health check endpoint path | +| `READINESS_PATH` | `/ready` | Kubernetes readiness probe path | +| `LIVENESS_PATH` | `/live` | Kubernetes liveness probe path | + +### Accessing Health Checker in Handlers + +You can access the health checker in your HTTP handlers: + +```go +func myHandler(w http.ResponseWriter, r *http.Request) { + healthChecker := service.GetHealthChecker(r) + if healthChecker != nil { + status, err := healthChecker.Measure(r.Context()) + if err != nil { + // Handle error + } + // Use status information + } +} +``` + ## Kubernetes Deployment The library provides all the boilerplate needed for Kubernetes deployments: @@ -255,9 +364,11 @@ go run main.go Test the endpoints: ```bash curl http://localhost:8080/ -curl http://localhost:8080/health curl http://localhost:8080/metrics-demo -curl http://localhost:9090/metrics +curl http://localhost:9090/health # Comprehensive health check +curl http://localhost:9090/ready # Readiness probe +curl http://localhost:9090/live # Liveness probe +curl http://localhost:9090/metrics # Prometheus metrics ``` ## Best Practices diff --git a/_example/main.go b/_example/main.go index 3d76eff..0679553 100644 --- a/_example/main.go +++ b/_example/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "log/slog" "net/http" @@ -8,6 +9,7 @@ import ( "time" "atomicgo.dev/service" + "github.com/hellofresh/health-go/v5" ) func main() { @@ -29,6 +31,62 @@ func main() { }) }) + // Register health checks + svc.RegisterHealthCheck(health.Config{ + Name: "database", + Timeout: time.Second * 5, + SkipOnErr: false, // This check is critical + Check: func(ctx context.Context) error { + // Simulate database health check + // In a real application, you would check your database connection + slog.Info("checking database health") + time.Sleep(100 * time.Millisecond) // Simulate some work + return nil // Return nil for healthy, error for unhealthy + }, + }) + + svc.RegisterHealthCheck(health.Config{ + Name: "cache", + Timeout: time.Second * 3, + SkipOnErr: true, // This check is optional + Check: func(ctx context.Context) error { + // Simulate cache health check + // In a real application, you would check your Redis/Memcached connection + slog.Info("checking cache health") + time.Sleep(50 * time.Millisecond) // Simulate some work + return nil // Return nil for healthy, error for unhealthy + }, + }) + + svc.RegisterHealthCheck(health.Config{ + Name: "external-api", + Timeout: time.Second * 10, + SkipOnErr: true, // External dependencies are often optional + Check: func(ctx context.Context) error { + // Simulate external API health check + slog.Info("checking external API health") + + // Create a simple HTTP request to check external service + client := &http.Client{Timeout: 5 * time.Second} + req, err := http.NewRequestWithContext(ctx, "GET", "https://httpbin.org/status/200", nil) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("external API returned status %d", resp.StatusCode) + } + + return nil + }, + }) + // Add shutdown hook to demonstrate graceful shutdown svc.AddShutdownHook(func() error { slog.Info("cleaning up resources...") @@ -39,11 +97,17 @@ func main() { // Register handlers svc.HandleFunc("/", handleHelloWorld) - svc.HandleFunc("/health", handleHealth) + svc.HandleFunc("/health-demo", handleHealthDemo) svc.HandleFunc("/metrics-demo", handleMetricsDemo) // Start service with graceful shutdown slog.Info("starting service with graceful shutdown support") + slog.Info("health endpoints available at:") + slog.Info(" - http://localhost:9090/health (comprehensive health check)") + slog.Info(" - http://localhost:9090/ready (readiness probe)") + slog.Info(" - http://localhost:9090/live (liveness probe)") + slog.Info(" - http://localhost:9090/metrics (prometheus metrics)") + if err := svc.Start(); err != nil { svc.Logger.Error("failed to start service", "error", err) os.Exit(1) @@ -60,29 +124,49 @@ func handleHelloWorld(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Hello, World!")) } -func handleHealth(w http.ResponseWriter, r *http.Request) { +func handleHealthDemo(w http.ResponseWriter, r *http.Request) { logger := service.GetLogger(r) - logger.Info("health check called") + logger.Info("health demo endpoint called") + + // Access the health checker from the request context + healthChecker := service.GetHealthChecker(r) + if healthChecker == nil { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("Health checker not available")) + return + } - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) + // Get current health status + check := healthChecker.Measure(r.Context()) + + // Return health status information + w.Header().Set("Content-Type", "application/json") + if check.Status == "OK" { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusServiceUnavailable) + } + + // In a real application, you might want to use json.Marshal + response := fmt.Sprintf(`{ + "status": "%s", + "timestamp": "%s", + "component": { + "name": "%s", + "version": "%s" + } + }`, check.Status, check.Timestamp.Format(time.RFC3339), check.Component.Name, check.Component.Version) + + w.Write([]byte(response)) } func handleMetricsDemo(w http.ResponseWriter, r *http.Request) { logger := service.GetLogger(r) logger.Info("metrics demo endpoint called") - // Simulate some work + // Simulate some work that might be measured time.Sleep(100 * time.Millisecond) - // Example of using metrics in a handler - // The metrics middleware automatically tracks requests, but you can also - // interact with metrics manually if needed - metrics := service.GetMetrics(r) - if metrics != nil { - logger.Info("metrics collector is available in context") - } - w.WriteHeader(http.StatusOK) - w.Write([]byte(fmt.Sprintf("Metrics demo completed. Check :9090/metrics for Prometheus metrics."))) + w.Write([]byte("Metrics demo - check /metrics endpoint for Prometheus metrics")) } diff --git a/config.go b/config.go index 737ad5d..87d821a 100644 --- a/config.go +++ b/config.go @@ -23,6 +23,14 @@ type Config struct { // Graceful shutdown configuration ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"30s"` + // Service information + Version string `env:"SERVICE_VERSION" envDefault:"v1.0.0"` + + // Health check configuration + HealthPath string `env:"HEALTH_PATH" envDefault:"/health"` + ReadinessPath string `env:"READINESS_PATH" envDefault:"/ready"` + LivenessPath string `env:"LIVENESS_PATH" envDefault:"/live"` + // Logger configuration Logger *slog.Logger `env:"-"` @@ -40,6 +48,10 @@ func DefaultConfig() *Config { MetricsAddr: ":9090", MetricsPath: "/metrics", ShutdownTimeout: 30 * time.Second, + Version: "v1.0.0", + HealthPath: "/health", + ReadinessPath: "/ready", + LivenessPath: "/live", Logger: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})), ShutdownHooks: make([]func() error, 0), } diff --git a/go.mod b/go.mod index 53ffb1f..0903c5a 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24 require ( github.com/caarlos0/env/v11 v11.3.1 + github.com/hellofresh/health-go/v5 v5.5.5 github.com/prometheus/client_golang v1.22.0 ) @@ -14,6 +15,8 @@ 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 + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect golang.org/x/sys v0.30.0 // indirect google.golang.org/protobuf v1.36.5 // indirect ) diff --git a/go.sum b/go.sum index 87f5699..7985aa2 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/hellofresh/health-go/v5 v5.5.5 h1:JZwZ8kZzAgjdGCvjgrIJTcu1sImvZoHbwAj7CK19fpw= +github.com/hellofresh/health-go/v5 v5.5.5/go.mod h1:W+6uiWHS/m9jaB0aYBVlUBTeyE98yom6f+0ewLoBPYQ= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -24,8 +26,14 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= diff --git a/health.go b/health.go new file mode 100644 index 0000000..44e81ff --- /dev/null +++ b/health.go @@ -0,0 +1,115 @@ +package service + +import ( + "context" + "net/http" + "time" + + "github.com/hellofresh/health-go/v5" +) + +// HealthChecker wraps the health-go library health checker +type HealthChecker struct { + checker *health.Health +} + +// NewHealthChecker creates a new health checker with the service component information +func NewHealthChecker(serviceName, version string) (*HealthChecker, error) { + checker, err := health.New( + health.WithComponent(health.Component{ + Name: serviceName, + Version: version, + }), + ) + if err != nil { + return nil, err + } + + return &HealthChecker{ + checker: checker, + }, nil +} + +// Register adds a health check to the health checker +func (hc *HealthChecker) Register(config health.Config) { + hc.checker.Register(config) +} + +// Handler returns the HTTP handler for health checks +func (hc *HealthChecker) Handler() http.Handler { + return hc.checker.Handler() +} + +// HandlerFunc returns the HTTP handler function for health checks +func (hc *HealthChecker) HandlerFunc(w http.ResponseWriter, r *http.Request) { + hc.checker.HandlerFunc(w, r) +} + +// Measure returns the current health status +func (hc *HealthChecker) Measure(ctx context.Context) health.Check { + return hc.checker.Measure(ctx) +} + +// IsHealthy returns true if all health checks are passing +func (hc *HealthChecker) IsHealthy(ctx context.Context) bool { + check := hc.Measure(ctx) + return check.Status == health.StatusOK +} + +// IsReady returns true if the service is ready to serve requests +// This is typically used for Kubernetes readiness probes +func (hc *HealthChecker) IsReady(ctx context.Context) bool { + // For readiness, we want to check if critical services are available + // This is the same as health check for now, but can be customized + return hc.IsHealthy(ctx) +} + +// IsAlive returns true if the service is alive +// This is typically used for Kubernetes liveness probes +func (hc *HealthChecker) IsAlive(ctx context.Context) bool { + // For liveness, we want to check if the service is still running + // This should be more lenient than health checks + // For now, we'll just return true as the service is running if this is called + return true +} + +// ReadinessHandler returns an HTTP handler for readiness checks +func (hc *HealthChecker) ReadinessHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if hc.IsReady(ctx) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Ready")) + } else { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("Not Ready")) + } + } +} + +// LivenessHandler returns an HTTP handler for liveness checks +func (hc *HealthChecker) LivenessHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if hc.IsAlive(ctx) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Alive")) + } else { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("Not Alive")) + } + } +} + +// GetHealthChecker retrieves the health checker from the request context +func GetHealthChecker(r *http.Request) *HealthChecker { + hc, ok := r.Context().Value(HealthCheckerKey).(*HealthChecker) + if !ok { + return nil + } + return hc +} diff --git a/health_test.go b/health_test.go new file mode 100644 index 0000000..930aac9 --- /dev/null +++ b/health_test.go @@ -0,0 +1,361 @@ +package service + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/hellofresh/health-go/v5" +) + +func TestNewHealthChecker(t *testing.T) { + t.Run("creates health checker successfully", func(t *testing.T) { + hc, err := NewHealthChecker("test-service", "v1.0.0") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if hc == nil { + t.Fatal("expected health checker to be created") + } + + if hc.checker == nil { + t.Fatal("expected internal health checker to be initialized") + } + }) +} + +func TestHealthChecker_Register(t *testing.T) { + hc, err := NewHealthChecker("test-service", "v1.0.0") + if err != nil { + t.Fatalf("failed to create health checker: %v", err) + } + + // Register a simple health check + hc.Register(health.Config{ + Name: "test-check", + Check: func(ctx context.Context) error { + return nil + }, + }) + + // Verify health check was registered by measuring health + check := hc.Measure(context.Background()) + + if check.Status != health.StatusOK { + t.Errorf("expected status OK, got %s", check.Status) + } +} + +func TestHealthChecker_IsHealthy(t *testing.T) { + hc, err := NewHealthChecker("test-service", "v1.0.0") + if err != nil { + t.Fatalf("failed to create health checker: %v", err) + } + + t.Run("returns true when all checks pass", func(t *testing.T) { + hc.Register(health.Config{ + Name: "passing-check", + Check: func(ctx context.Context) error { + return nil + }, + }) + + if !hc.IsHealthy(context.Background()) { + t.Error("expected IsHealthy to return true") + } + }) + + t.Run("returns false when check fails", func(t *testing.T) { + hc.Register(health.Config{ + Name: "failing-check", + Check: func(ctx context.Context) error { + return errors.New("check failed") + }, + }) + + if hc.IsHealthy(context.Background()) { + t.Error("expected IsHealthy to return false") + } + }) +} + +func TestHealthChecker_IsReady(t *testing.T) { + hc, err := NewHealthChecker("test-service", "v1.0.0") + if err != nil { + t.Fatalf("failed to create health checker: %v", err) + } + + t.Run("returns true when healthy", func(t *testing.T) { + hc.Register(health.Config{ + Name: "ready-check", + Check: func(ctx context.Context) error { + return nil + }, + }) + + if !hc.IsReady(context.Background()) { + t.Error("expected IsReady to return true") + } + }) +} + +func TestHealthChecker_IsAlive(t *testing.T) { + hc, err := NewHealthChecker("test-service", "v1.0.0") + if err != nil { + t.Fatalf("failed to create health checker: %v", err) + } + + // IsAlive should always return true for a running service + if !hc.IsAlive(context.Background()) { + t.Error("expected IsAlive to return true") + } +} + +func TestHealthChecker_Handlers(t *testing.T) { + hc, err := NewHealthChecker("test-service", "v1.0.0") + if err != nil { + t.Fatalf("failed to create health checker: %v", err) + } + + // Register a health check + hc.Register(health.Config{ + Name: "test-check", + Check: func(ctx context.Context) error { + return nil + }, + }) + + t.Run("Handler returns 200 for healthy service", func(t *testing.T) { + req := httptest.NewRequest("GET", "/health", nil) + recorder := httptest.NewRecorder() + + hc.Handler().ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } + }) + + t.Run("HandlerFunc returns 200 for healthy service", func(t *testing.T) { + req := httptest.NewRequest("GET", "/health", nil) + recorder := httptest.NewRecorder() + + hc.HandlerFunc(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } + }) + + t.Run("ReadinessHandler returns 200 for ready service", func(t *testing.T) { + req := httptest.NewRequest("GET", "/ready", nil) + recorder := httptest.NewRecorder() + + hc.ReadinessHandler()(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } + + if recorder.Body.String() != "Ready" { + t.Errorf("expected body 'Ready', got %s", recorder.Body.String()) + } + }) + + t.Run("LivenessHandler returns 200 for alive service", func(t *testing.T) { + req := httptest.NewRequest("GET", "/live", nil) + recorder := httptest.NewRecorder() + + hc.LivenessHandler()(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } + + if recorder.Body.String() != "Alive" { + t.Errorf("expected body 'Alive', got %s", recorder.Body.String()) + } + }) +} + +func TestHealthChecker_HandlersWithFailures(t *testing.T) { + hc, err := NewHealthChecker("test-service", "v1.0.0") + if err != nil { + t.Fatalf("failed to create health checker: %v", err) + } + + // Register a failing health check + hc.Register(health.Config{ + Name: "failing-check", + Check: func(ctx context.Context) error { + return errors.New("check failed") + }, + }) + + t.Run("ReadinessHandler returns 503 for not ready service", func(t *testing.T) { + req := httptest.NewRequest("GET", "/ready", nil) + recorder := httptest.NewRecorder() + + hc.ReadinessHandler()(recorder, req) + + if recorder.Code != http.StatusServiceUnavailable { + t.Errorf("expected status 503, got %d", recorder.Code) + } + + if recorder.Body.String() != "Not Ready" { + t.Errorf("expected body 'Not Ready', got %s", recorder.Body.String()) + } + }) +} + +func TestGetHealthChecker(t *testing.T) { + hc, err := NewHealthChecker("test-service", "v1.0.0") + if err != nil { + t.Fatalf("failed to create health checker: %v", err) + } + + t.Run("returns health checker from context", func(t *testing.T) { + handler := HealthCheckerMiddleware(hc)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + retrievedHC := GetHealthChecker(r) + if retrievedHC == nil { + t.Error("expected health checker to be retrieved from context") + } + if retrievedHC != hc { + t.Error("expected retrieved health checker to match original") + } + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/test", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } + }) + + t.Run("returns nil when not in context", func(t *testing.T) { + req := httptest.NewRequest("GET", "/test", nil) + + retrievedHC := GetHealthChecker(req) + if retrievedHC != nil { + t.Error("expected health checker to be nil when not in context") + } + }) +} + +func TestHealthCheckerMiddleware(t *testing.T) { + hc, err := NewHealthChecker("test-service", "v1.0.0") + if err != nil { + t.Fatalf("failed to create health checker: %v", err) + } + + middleware := HealthCheckerMiddleware(hc) + + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify health checker is available in context + retrievedHC := GetHealthChecker(r) + if retrievedHC == nil { + t.Error("health checker should be available in context") + } + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/test", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } +} + +func TestHealthChecker_Timeout(t *testing.T) { + hc, err := NewHealthChecker("test-service", "v1.0.0") + if err != nil { + t.Fatalf("failed to create health checker: %v", err) + } + + // Register a health check with a timeout + hc.Register(health.Config{ + Name: "slow-check", + Timeout: 100 * time.Millisecond, + Check: func(ctx context.Context) error { + // Simulate a slow operation + time.Sleep(200 * time.Millisecond) + return nil + }, + }) + + // This should timeout + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + if hc.IsHealthy(ctx) { + t.Error("expected health check to fail due to timeout") + } +} + +func TestService_RegisterHealthCheck(t *testing.T) { + svc := New("test-service", nil) + + t.Run("registers health check successfully", func(t *testing.T) { + checkCalled := false + svc.RegisterHealthCheck(health.Config{ + Name: "test-check", + Check: func(ctx context.Context) error { + checkCalled = true + return nil + }, + }) + + // Verify the check was registered by measuring health + if svc.HealthChecker != nil { + check := svc.HealthChecker.Measure(context.Background()) + + if check.Status != health.StatusOK { + t.Errorf("expected status OK, got %s", check.Status) + } + + if !checkCalled { + t.Error("expected health check to be called") + } + } + }) + + t.Run("handles nil health checker gracefully", func(t *testing.T) { + svcWithoutHealth := &Service{ + Name: "test", + Logger: svc.Logger, + HealthChecker: nil, + } + + // This should not panic + svcWithoutHealth.RegisterHealthCheck(health.Config{ + Name: "test-check", + Check: func(ctx context.Context) error { + return nil + }, + }) + }) +} + +func TestService_GetHealthChecker(t *testing.T) { + svc := New("test-service", nil) + + hc := svc.GetHealthChecker() + if hc == nil { + t.Error("expected health checker to be available") + } + + if hc != svc.HealthChecker { + t.Error("expected returned health checker to match service health checker") + } +} diff --git a/metrics.go b/metrics.go index c61cdaf..e26d2ce 100644 --- a/metrics.go +++ b/metrics.go @@ -148,11 +148,31 @@ func (s *Service) startMetricsServer() error { mux := http.NewServeMux() mux.Handle(s.Config.MetricsPath, promhttp.Handler()) - // Add health check endpoint - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) - }) + // Add health check endpoints + if s.HealthChecker != nil { + // Main health check endpoint (comprehensive health status) + mux.Handle(s.Config.HealthPath, s.HealthChecker.Handler()) + + // Kubernetes readiness probe endpoint + mux.HandleFunc(s.Config.ReadinessPath, s.HealthChecker.ReadinessHandler()) + + // Kubernetes liveness probe endpoint + mux.HandleFunc(s.Config.LivenessPath, s.HealthChecker.LivenessHandler()) + } else { + // Fallback basic health endpoints if health checker is not available + mux.HandleFunc(s.Config.HealthPath, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + mux.HandleFunc(s.Config.ReadinessPath, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Ready")) + }) + mux.HandleFunc(s.Config.LivenessPath, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Alive")) + }) + } s.metricsServer = &http.Server{ Addr: s.Config.MetricsAddr, diff --git a/middleware.go b/middleware.go index f23f040..cf1e009 100644 --- a/middleware.go +++ b/middleware.go @@ -14,6 +14,8 @@ const ( LoggerKey ContextKey = "logger" // MetricsKey is the context key for metrics MetricsKey ContextKey = "metrics" + // HealthCheckerKey is the context key for the health checker + HealthCheckerKey ContextKey = "health_checker" ) // Middleware represents a middleware function @@ -76,6 +78,20 @@ func RequestLoggingMiddleware(logger *slog.Logger) Middleware { } } +// HealthCheckerMiddleware injects the health checker into the request context +func HealthCheckerMiddleware(healthChecker *HealthChecker) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Add health checker to context + ctx := context.WithValue(r.Context(), HealthCheckerKey, healthChecker) + r = r.WithContext(ctx) + + // Call the next handler + next.ServeHTTP(w, r) + }) + } +} + // applyMiddleware applies multiple middleware functions to a handler func applyMiddleware(h http.Handler, middlewares ...Middleware) http.Handler { for i := len(middlewares) - 1; i >= 0; i-- { diff --git a/service.go b/service.go index 1962f88..88367a1 100644 --- a/service.go +++ b/service.go @@ -6,14 +6,17 @@ import ( "os" "os/signal" "syscall" + + "github.com/hellofresh/health-go/v5" ) // Service represents the main service instance type Service struct { - Name string - Config *Config - Logger *slog.Logger - Metrics *MetricsCollector + Name string + Config *Config + Logger *slog.Logger + Metrics *MetricsCollector + HealthChecker *HealthChecker server *http.Server metricsServer *http.Server @@ -30,12 +33,21 @@ func New(name string, config *Config) *Service { // Create metrics collector metrics := NewMetricsCollector(name) + // Create health checker + healthChecker, err := NewHealthChecker(name, config.Version) + if err != nil { + config.Logger.Error("failed to create health checker", "error", err) + // Continue without health checker - it's not critical for basic operation + healthChecker = nil + } + svc := &Service{ - Name: name, - Config: config, - Logger: config.Logger, - Metrics: metrics, - mux: http.NewServeMux(), + Name: name, + Config: config, + Logger: config.Logger, + Metrics: metrics, + HealthChecker: healthChecker, + mux: http.NewServeMux(), } // Add default middleware (order matters: metrics should be first to capture all requests) @@ -46,6 +58,11 @@ func New(name string, config *Config) *Service { RequestLoggingMiddleware(config.Logger), } + // Add health checker middleware if available + if healthChecker != nil { + svc.middlewares = append(svc.middlewares, HealthCheckerMiddleware(healthChecker)) + } + return svc } @@ -114,3 +131,17 @@ func (s *Service) Start() error { // Perform graceful shutdown return s.gracefulShutdown() } + +// RegisterHealthCheck adds a health check to the service +func (s *Service) RegisterHealthCheck(config health.Config) { + if s.HealthChecker != nil { + s.HealthChecker.Register(config) + } else { + s.Logger.Warn("health checker not available, skipping health check registration", "name", config.Name) + } +} + +// GetHealthChecker returns the health checker instance +func (s *Service) GetHealthChecker() *HealthChecker { + return s.HealthChecker +} From 2279e9f416d535cf59c00ec5173352a48bc56a9f Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Fri, 11 Jul 2025 23:13:04 +0200 Subject: [PATCH 05/17] chore: add lib/pq dependency and remove example main.go file - Added `github.com/lib/pq` as an indirect dependency in `go.mod` and updated `go.sum`. - Removed the example `main.go` file as it is no longer needed. --- {_example => _examples/demo}/main.go | 17 +- _examples/health-check-custom/main.go | 111 +++++++++ _examples/health-check-postgresql/main.go | 86 +++++++ _examples/minimal/main.go | 31 +++ _examples/prometheus-counter/main.go | 280 ++++++++++++++++++++++ _examples/shutdown-hook/main.go | 207 ++++++++++++++++ go.mod | 1 + go.sum | 2 + 8 files changed, 727 insertions(+), 8 deletions(-) rename {_example => _examples/demo}/main.go (89%) create mode 100644 _examples/health-check-custom/main.go create mode 100644 _examples/health-check-postgresql/main.go create mode 100644 _examples/minimal/main.go create mode 100644 _examples/prometheus-counter/main.go create mode 100644 _examples/shutdown-hook/main.go diff --git a/_example/main.go b/_examples/demo/main.go similarity index 89% rename from _example/main.go rename to _examples/demo/main.go index 0679553..11932ee 100644 --- a/_example/main.go +++ b/_examples/demo/main.go @@ -16,6 +16,7 @@ func main() { // Load configuration from environment variables config, err := service.LoadFromEnv() if err != nil { + // We can't use svc.Logger here yet, so we'll use slog for this error slog.Error("failed to load config", "error", err) os.Exit(1) } @@ -68,7 +69,7 @@ func main() { // Create a simple HTTP request to check external service client := &http.Client{Timeout: 5 * time.Second} - req, err := http.NewRequestWithContext(ctx, "GET", "https://httpbin.org/status/200", nil) + req, err := http.NewRequestWithContext(ctx, "GET", "https://httb.dev/status/200", nil) if err != nil { return err } @@ -101,19 +102,19 @@ func main() { svc.HandleFunc("/metrics-demo", handleMetricsDemo) // Start service with graceful shutdown - slog.Info("starting service with graceful shutdown support") - slog.Info("health endpoints available at:") - slog.Info(" - http://localhost:9090/health (comprehensive health check)") - slog.Info(" - http://localhost:9090/ready (readiness probe)") - slog.Info(" - http://localhost:9090/live (liveness probe)") - slog.Info(" - http://localhost:9090/metrics (prometheus metrics)") + svc.Logger.Info("starting service with graceful shutdown support") + svc.Logger.Info("health endpoints available at:") + svc.Logger.Info(" - http://localhost:9090/health (comprehensive health check)") + svc.Logger.Info(" - http://localhost:9090/ready (readiness probe)") + svc.Logger.Info(" - http://localhost:9090/live (liveness probe)") + svc.Logger.Info(" - http://localhost:9090/metrics (prometheus metrics)") if err := svc.Start(); err != nil { svc.Logger.Error("failed to start service", "error", err) os.Exit(1) } - slog.Info("service stopped") + svc.Logger.Info("service stopped") } func handleHelloWorld(w http.ResponseWriter, r *http.Request) { diff --git a/_examples/health-check-custom/main.go b/_examples/health-check-custom/main.go new file mode 100644 index 0000000..e6958b2 --- /dev/null +++ b/_examples/health-check-custom/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "sync/atomic" + "time" + + "atomicgo.dev/service" + "github.com/hellofresh/health-go/v5" +) + +var ( + // Simulate application state + isReady int64 = 0 + startTime = time.Now() + requestCount int64 +) + +func main() { + svc := service.New("custom-health-service", nil) + + // Custom readiness check - simulates application warm-up + svc.RegisterHealthCheck(health.Config{ + Name: "readiness", + Timeout: time.Second * 3, + SkipOnErr: false, + Check: func(ctx context.Context) error { + if atomic.LoadInt64(&isReady) == 0 { + return fmt.Errorf("application is still warming up") + } + return nil + }, + }) + + // Custom uptime check + svc.RegisterHealthCheck(health.Config{ + Name: "uptime", + Timeout: time.Second * 1, + SkipOnErr: true, // Non-critical check + Check: func(ctx context.Context) error { + uptime := time.Since(startTime) + if uptime < 10*time.Second { + return fmt.Errorf("service recently started (uptime: %v)", uptime) + } + return nil + }, + }) + + // Custom external dependency check + svc.RegisterHealthCheck(health.Config{ + Name: "external-api", + Timeout: time.Second * 5, + SkipOnErr: true, // External dependencies are often optional + Check: func(ctx context.Context) error { + // Check external service availability + client := &http.Client{Timeout: 3 * time.Second} + req, err := http.NewRequestWithContext(ctx, "GET", "https://httpbin.org/status/200", nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("external API unreachable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("external API returned status %d", resp.StatusCode) + } + + return nil + }, + }) + + // Main handler + svc.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + logger.Info("Request received") + + // Increment request counter + atomic.AddInt64(&requestCount, 1) + + w.Write([]byte("Custom Health Check Service\n")) + w.Write([]byte(fmt.Sprintf("Uptime: %v\n", time.Since(startTime)))) + w.Write([]byte(fmt.Sprintf("Request count: %d\n", atomic.LoadInt64(&requestCount)))) + w.Write([]byte("Health check at: /health\n")) + }) + + // Simulate application warm-up + go func() { + svc.Logger.Info("Starting application warm-up...") + time.Sleep(5 * time.Second) + atomic.StoreInt64(&isReady, 1) + svc.Logger.Info("Application warm-up complete, now ready") + }() + + svc.Logger.Info("Starting custom health check service...") + svc.Logger.Info("Service available at http://localhost:8080") + svc.Logger.Info("Health check at http://localhost:9090/health") + svc.Logger.Info("Toggle readiness with: POST http://localhost:8080/ready") + svc.Logger.Info("View stats at http://localhost:8080/stats") + + if err := svc.Start(); err != nil { + svc.Logger.Error("Failed to start service", "error", err) + os.Exit(1) + } +} diff --git a/_examples/health-check-postgresql/main.go b/_examples/health-check-postgresql/main.go new file mode 100644 index 0000000..46026e6 --- /dev/null +++ b/_examples/health-check-postgresql/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "database/sql" + "fmt" + "net/http" + "os" + "time" + + "atomicgo.dev/service" + "github.com/hellofresh/health-go/v5" + healthPostgres "github.com/hellofresh/health-go/v5/checks/postgres" + _ "github.com/lib/pq" // PostgreSQL driver +) + +func main() { + // Database connection string - in production, use environment variables + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + dbURL = "postgres://user:password@localhost/dbname?sslmode=disable" + } + + // Create service + svc := service.New("postgresql-health-service", nil) + + // Register PostgreSQL health check using built-in checker + svc.RegisterHealthCheck(health.Config{ + Name: "postgresql", + Timeout: time.Second * 5, + SkipOnErr: false, // Critical check - service is unhealthy if DB is down + Check: healthPostgres.New(healthPostgres.Config{ + DSN: dbURL, + }), + }) + + // Open database connection for stats endpoint + db, err := sql.Open("postgres", dbURL) + if err != nil { + svc.Logger.Error("Failed to open database connection", "error", err) + os.Exit(1) + } + defer db.Close() + + // Configure connection pool + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(25) + db.SetConnMaxLifetime(5 * time.Minute) + + // Simple handler + svc.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + logger.Info("PostgreSQL health check service") + w.Write([]byte("PostgreSQL Health Check Service\n")) + w.Write([]byte("Check health at: /health\n")) + }) + + // Database stats endpoint + svc.HandleFunc("/db-stats", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + logger.Info("Database stats requested") + + stats := db.Stats() + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(fmt.Sprintf(`{ + "open_connections": %d, + "in_use": %d, + "idle": %d, + "wait_count": %d, + "wait_duration": "%s", + "max_idle_closed": %d, + "max_lifetime_closed": %d + }`, stats.OpenConnections, stats.InUse, stats.Idle, stats.WaitCount, + stats.WaitDuration, stats.MaxIdleClosed, stats.MaxLifetimeClosed))) + }) + + svc.Logger.Info("Starting PostgreSQL health check service...") + svc.Logger.Info("Service available at http://localhost:8080") + svc.Logger.Info("Health check at http://localhost:9090/health") + svc.Logger.Info("Database stats at http://localhost:8080/db-stats") + svc.Logger.Info("Database URL: " + dbURL) + + if err := svc.Start(); err != nil { + svc.Logger.Error("Failed to start service", "error", err) + os.Exit(1) + } +} diff --git a/_examples/minimal/main.go b/_examples/minimal/main.go new file mode 100644 index 0000000..f14cb82 --- /dev/null +++ b/_examples/minimal/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "net/http" + "os" + + "atomicgo.dev/service" +) + +func main() { + // Create service with default configuration + svc := service.New("minimal-service", nil) + + // Simple handler + svc.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + logger.Info("Hello from minimal service!") + w.Write([]byte("Hello from minimal service!")) + }) + + // Start service - includes graceful shutdown, metrics, and health checks + svc.Logger.Info("Starting minimal service...") + svc.Logger.Info("Service available at http://localhost:8080") + svc.Logger.Info("Health check at http://localhost:9090/health") + svc.Logger.Info("Metrics at http://localhost:9090/metrics") + + if err := svc.Start(); err != nil { + svc.Logger.Error("Failed to start service", "error", err) + os.Exit(1) + } +} diff --git a/_examples/prometheus-counter/main.go b/_examples/prometheus-counter/main.go new file mode 100644 index 0000000..f9ca79e --- /dev/null +++ b/_examples/prometheus-counter/main.go @@ -0,0 +1,280 @@ +package main + +import ( + "fmt" + "math/rand" + "net/http" + "os" + "strconv" + "time" + + "atomicgo.dev/service" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + // Custom counters + requestCounter = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "myapp_requests_total", + Help: "Total number of requests processed", + }, + []string{"method", "endpoint", "status"}, + ) + + businessMetricCounter = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "myapp_business_events_total", + Help: "Total number of business events processed", + }, + []string{"event_type", "result"}, + ) + + // Custom gauges + activeUsersGauge = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "myapp_active_users", + Help: "Number of currently active users", + }, + ) + + queueSizeGauge = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "myapp_queue_size", + Help: "Current size of processing queues", + }, + []string{"queue_name"}, + ) + + // Custom histograms + processingDuration = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "myapp_processing_duration_seconds", + Help: "Time spent processing requests", + Buckets: prometheus.DefBuckets, + }, + []string{"operation"}, + ) + + // Custom summary + requestSize = promauto.NewSummaryVec( + prometheus.SummaryOpts{ + Name: "myapp_request_size_bytes", + Help: "Size of requests in bytes", + Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, + }, + []string{"endpoint"}, + ) +) + +func main() { + svc := service.New("prometheus-counter-service", nil) + + // Simulate background metrics collection + go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + // Simulate changing active users + activeUsersGauge.Set(float64(rand.Intn(100) + 50)) + + // Simulate queue sizes + queueSizeGauge.WithLabelValues("email").Set(float64(rand.Intn(20))) + queueSizeGauge.WithLabelValues("notifications").Set(float64(rand.Intn(50))) + queueSizeGauge.WithLabelValues("analytics").Set(float64(rand.Intn(30))) + + // Simulate some business events + eventTypes := []string{"user_signup", "purchase", "login", "logout"} + results := []string{"success", "failure"} + + for i := 0; i < rand.Intn(5); i++ { + eventType := eventTypes[rand.Intn(len(eventTypes))] + result := results[rand.Intn(len(results))] + businessMetricCounter.WithLabelValues(eventType, result).Inc() + } + } + } + }() + + // Custom middleware to track request metrics + svc.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Track request size + if r.ContentLength > 0 { + requestSize.WithLabelValues(r.URL.Path).Observe(float64(r.ContentLength)) + } + + // Wrap response writer to capture status code + wrapper := &responseWriter{ResponseWriter: w, statusCode: 200} + + // Process request + next.ServeHTTP(wrapper, r) + + // Record metrics + duration := time.Since(start) + requestCounter.WithLabelValues(r.Method, r.URL.Path, strconv.Itoa(wrapper.statusCode)).Inc() + processingDuration.WithLabelValues("http_request").Observe(duration.Seconds()) + }) + }) + + // Main handler + svc.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + logger.Info("Home page requested") + + w.Write([]byte("Prometheus Counter Demo Service\n")) + w.Write([]byte("Available endpoints:\n")) + w.Write([]byte(" - /api/users (GET, POST)\n")) + w.Write([]byte(" - /api/orders (GET, POST)\n")) + w.Write([]byte(" - /api/process (POST)\n")) + w.Write([]byte(" - /metrics (Prometheus metrics)\n")) + }) + + // Users API endpoint + svc.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + + switch r.Method { + case "GET": + logger.Info("Fetching users") + // Simulate processing time + time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond) + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"users": ["alice", "bob", "charlie"]}`)) + + case "POST": + logger.Info("Creating user") + // Simulate processing time + time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond) + + // Simulate success/failure + if rand.Float32() < 0.9 { + businessMetricCounter.WithLabelValues("user_creation", "success").Inc() + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"status": "created"}`)) + } else { + businessMetricCounter.WithLabelValues("user_creation", "failure").Inc() + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error": "validation failed"}`)) + } + + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + + // Orders API endpoint + svc.HandleFunc("/api/orders", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + + switch r.Method { + case "GET": + logger.Info("Fetching orders") + time.Sleep(time.Duration(rand.Intn(150)) * time.Millisecond) + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"orders": [{"id": 1, "amount": 100}, {"id": 2, "amount": 200}]}`)) + + case "POST": + logger.Info("Creating order") + time.Sleep(time.Duration(rand.Intn(300)) * time.Millisecond) + + // Simulate success/failure + if rand.Float32() < 0.8 { + businessMetricCounter.WithLabelValues("order_creation", "success").Inc() + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"status": "created", "order_id": 123}`)) + } else { + businessMetricCounter.WithLabelValues("order_creation", "failure").Inc() + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error": "insufficient funds"}`)) + } + + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + + // Heavy processing endpoint + svc.HandleFunc("/api/process", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + logger := service.GetLogger(r) + logger.Info("Processing heavy operation") + + // Simulate heavy processing + start := time.Now() + time.Sleep(time.Duration(rand.Intn(1000)+500) * time.Millisecond) + duration := time.Since(start) + + // Record processing duration + processingDuration.WithLabelValues("heavy_processing").Observe(duration.Seconds()) + + // Simulate success/failure + if rand.Float32() < 0.7 { + businessMetricCounter.WithLabelValues("heavy_processing", "success").Inc() + w.WriteHeader(http.StatusOK) + w.Write([]byte(fmt.Sprintf(`{"status": "completed", "duration": "%v"}`, duration))) + } else { + businessMetricCounter.WithLabelValues("heavy_processing", "failure").Inc() + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "processing failed"}`)) + } + }) + + // Metrics summary endpoint + svc.HandleFunc("/metrics-summary", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + logger.Info("Metrics summary requested") + + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("Custom Metrics Summary\n")) + w.Write([]byte("======================\n\n")) + w.Write([]byte("Available custom metrics:\n")) + w.Write([]byte("- myapp_requests_total: Total HTTP requests by method, endpoint, and status\n")) + w.Write([]byte("- myapp_business_events_total: Business events by type and result\n")) + w.Write([]byte("- myapp_active_users: Current number of active users\n")) + w.Write([]byte("- myapp_queue_size: Size of processing queues\n")) + w.Write([]byte("- myapp_processing_duration_seconds: Request processing time\n")) + w.Write([]byte("- myapp_request_size_bytes: Size of incoming requests\n\n")) + w.Write([]byte("View all metrics at: http://localhost:9090/metrics\n")) + }) + + svc.Logger.Info("Starting Prometheus counter demo service...") + svc.Logger.Info("Service available at http://localhost:8080") + svc.Logger.Info("Prometheus metrics at http://localhost:9090/metrics") + svc.Logger.Info("Metrics summary at http://localhost:8080/metrics-summary") + svc.Logger.Info("") + svc.Logger.Info("Try making requests to generate metrics:") + svc.Logger.Info(" curl http://localhost:8080/api/users") + svc.Logger.Info(" curl -X POST http://localhost:8080/api/users") + svc.Logger.Info(" curl -X POST http://localhost:8080/api/process") + svc.Logger.Info("") + svc.Logger.Info("Then check metrics at: http://localhost:9090/metrics") + + if err := svc.Start(); err != nil { + svc.Logger.Error("Failed to start service", "error", err) + os.Exit(1) + } +} + +// responseWriter wraps http.ResponseWriter to capture status code +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} diff --git a/_examples/shutdown-hook/main.go b/_examples/shutdown-hook/main.go new file mode 100644 index 0000000..2fe4d40 --- /dev/null +++ b/_examples/shutdown-hook/main.go @@ -0,0 +1,207 @@ +package main + +import ( + "fmt" + "log/slog" + "net/http" + "os" + "sync" + "time" + + "atomicgo.dev/service" +) + +var startTime = time.Now() + +// Simulate various resources that need cleanup +type DatabaseConnection struct { + connected bool + mu sync.Mutex +} + +func (db *DatabaseConnection) Connect() error { + db.mu.Lock() + defer db.mu.Unlock() + + // Note: We can't use svc.Logger here as it's not available in this context + // In a real application, you'd pass the logger to this function + time.Sleep(100 * time.Millisecond) // Simulate connection time + db.connected = true + return nil +} + +func (db *DatabaseConnection) Close() error { + db.mu.Lock() + defer db.mu.Unlock() + + if !db.connected { + return nil + } + + // Note: We can't use svc.Logger here as it's not available in this context + // In a real application, you'd pass the logger to this function + time.Sleep(200 * time.Millisecond) // Simulate cleanup time + db.connected = false + return nil +} + +func (db *DatabaseConnection) IsConnected() bool { + db.mu.Lock() + defer db.mu.Unlock() + return db.connected +} + +type CacheService struct { + active bool + mu sync.Mutex +} + +func (c *CacheService) Start() error { + c.mu.Lock() + defer c.mu.Unlock() + + // Note: We can't use svc.Logger here as it's not available in this context + // In a real application, you'd pass the logger to this function + time.Sleep(50 * time.Millisecond) + c.active = true + return nil +} + +func (c *CacheService) Stop() error { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.active { + return nil + } + + // Note: We can't use svc.Logger here as it's not available in this context + // In a real application, you'd pass the logger to this function + time.Sleep(150 * time.Millisecond) + c.active = false + return nil +} + +func main() { + // Initialize resources + db := &DatabaseConnection{} + cache := &CacheService{} + + // Connect to resources + if err := db.Connect(); err != nil { + // We can't use svc.Logger here yet, so we'll use slog for this error + slog.Error("Failed to connect to database", "error", err) + os.Exit(1) + } + + if err := cache.Start(); err != nil { + // We can't use svc.Logger here yet, so we'll use slog for this error + slog.Error("Failed to start cache service", "error", err) + os.Exit(1) + } + + // Create service + svc := service.New("shutdown-hook-service", nil) + + // Register shutdown hooks in reverse order of initialization + // The last registered hook runs first during shutdown + + // Hook 1: Cache cleanup (runs first during shutdown) + svc.AddShutdownHook(func() error { + slog.Info("Shutdown hook: Cleaning up cache service...") + return cache.Stop() + }) + + // Hook 2: Database cleanup (runs second during shutdown) + svc.AddShutdownHook(func() error { + slog.Info("Shutdown hook: Cleaning up database connection...") + return db.Close() + }) + + // Hook 3: Final cleanup (runs last during shutdown) + svc.AddShutdownHook(func() error { + slog.Info("Shutdown hook: Performing final cleanup...") + + // Simulate final cleanup operations + slog.Info("Saving application state...") + time.Sleep(100 * time.Millisecond) + + slog.Info("Flushing logs...") + time.Sleep(50 * time.Millisecond) + + slog.Info("Final cleanup completed") + return nil + }) + + // Hook 4: Demonstrate error handling in shutdown hooks + svc.AddShutdownHook(func() error { + slog.Info("Shutdown hook: Demonstrating error handling...") + + // Simulate a non-critical error during shutdown + if time.Now().UnixNano()%2 == 0 { + slog.Warn("Non-critical error during shutdown (this is expected)") + return fmt.Errorf("simulated non-critical shutdown error") + } + + slog.Info("Shutdown hook completed without errors") + return nil + }) + + // Register handlers + svc.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + logger.Info("Request received") + + w.Write([]byte("Shutdown Hook Demo Service\n")) + w.Write([]byte(fmt.Sprintf("Database connected: %v\n", db.IsConnected()))) + w.Write([]byte("Send SIGTERM or SIGINT to trigger graceful shutdown\n")) + w.Write([]byte("Press Ctrl+C to test shutdown hooks\n")) + }) + + // Status endpoint + svc.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + logger.Info("Status check requested") + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(fmt.Sprintf(`{ + "database_connected": %v, + "cache_active": %v, + "uptime": "%v" + }`, db.IsConnected(), cache.active, time.Since(startTime)))) + }) + + // Simulate some background work + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if db.IsConnected() { + svc.Logger.Info("Background task: Database is healthy") + } + } + } + }() + + svc.Logger.Info("Starting shutdown hook demo service...") + svc.Logger.Info("Service available at http://localhost:8080") + svc.Logger.Info("Status at http://localhost:8080/status") + svc.Logger.Info("Health check at http://localhost:9090/health") + svc.Logger.Info("") + svc.Logger.Info("To test shutdown hooks:") + svc.Logger.Info(" - Press Ctrl+C") + svc.Logger.Info(" - Send SIGTERM: kill -TERM ") + svc.Logger.Info(" - Send SIGINT: kill -INT ") + svc.Logger.Info("") + svc.Logger.Info("Watch the logs to see shutdown hooks execute in order") + + if err := svc.Start(); err != nil { + svc.Logger.Error("Failed to start service", "error", err) + os.Exit(1) + } + + svc.Logger.Info("Service shutdown complete") +} diff --git a/go.mod b/go.mod index 0903c5a..54b3b0e 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect diff --git a/go.sum b/go.sum index 7985aa2..0899648 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= From 82419be88801d7f9e1c7741c54a79647d6cc4be8 Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Fri, 11 Jul 2025 23:36:22 +0200 Subject: [PATCH 06/17] fix: update PostgreSQL health check example in README and example code - Replaced MySQL health check with PostgreSQL in the README example. - Updated the example code to reflect the new PostgreSQL health check configuration. - Simplified the example handler to return a basic response. --- README.md | 17 ++++++------ _examples/health-check-postgresql/main.go | 33 ++--------------------- 2 files changed, 11 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 6478b67..78a35a3 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ The health-go library provides several built-in health checkers for common servi ```go import ( "github.com/hellofresh/health-go/v5" - healthMysql "github.com/hellofresh/health-go/v5/checks/mysql" + healthMysql "github.com/hellofresh/health-go/v5/checks/postgres" healthRedis "github.com/hellofresh/health-go/v5/checks/redis" ) @@ -286,13 +286,14 @@ func main() { svc := service.New("my-service", nil) // MySQL health check - svc.RegisterHealthCheck(health.Config{ - Name: "mysql", - Timeout: time.Second * 5, - Check: healthMysql.New(healthMysql.Config{ - DSN: "user:password@tcp(localhost:3306)/dbname", - }), - }) + svc.RegisterHealthCheck(health.Config{ + Name: "postgresql", + Timeout: time.Second * 5, + SkipOnErr: false, // Critical check - service is unhealthy if DB is down + Check: healthPostgres.New(healthPostgres.Config{ + DSN: dbURL, + }), + }) // Redis health check svc.RegisterHealthCheck(health.Config{ diff --git a/_examples/health-check-postgresql/main.go b/_examples/health-check-postgresql/main.go index 46026e6..2da0272 100644 --- a/_examples/health-check-postgresql/main.go +++ b/_examples/health-check-postgresql/main.go @@ -2,7 +2,6 @@ package main import ( "database/sql" - "fmt" "net/http" "os" "time" @@ -10,7 +9,7 @@ import ( "atomicgo.dev/service" "github.com/hellofresh/health-go/v5" healthPostgres "github.com/hellofresh/health-go/v5/checks/postgres" - _ "github.com/lib/pq" // PostgreSQL driver + _ "github.com/lib/pq" ) func main() { @@ -41,42 +40,14 @@ func main() { } defer db.Close() - // Configure connection pool - db.SetMaxOpenConns(25) - db.SetMaxIdleConns(25) - db.SetConnMaxLifetime(5 * time.Minute) - // Simple handler svc.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - logger.Info("PostgreSQL health check service") - w.Write([]byte("PostgreSQL Health Check Service\n")) - w.Write([]byte("Check health at: /health\n")) - }) - - // Database stats endpoint - svc.HandleFunc("/db-stats", func(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - logger.Info("Database stats requested") - - stats := db.Stats() - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(fmt.Sprintf(`{ - "open_connections": %d, - "in_use": %d, - "idle": %d, - "wait_count": %d, - "wait_duration": "%s", - "max_idle_closed": %d, - "max_lifetime_closed": %d - }`, stats.OpenConnections, stats.InUse, stats.Idle, stats.WaitCount, - stats.WaitDuration, stats.MaxIdleClosed, stats.MaxLifetimeClosed))) + w.Write([]byte("Hello, World!")) }) svc.Logger.Info("Starting PostgreSQL health check service...") svc.Logger.Info("Service available at http://localhost:8080") svc.Logger.Info("Health check at http://localhost:9090/health") - svc.Logger.Info("Database stats at http://localhost:8080/db-stats") svc.Logger.Info("Database URL: " + dbURL) if err := svc.Start(); err != nil { From 2c8672559f3b7073a5824771ba54ffdf33c8ab90 Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Fri, 11 Jul 2025 23:52:27 +0200 Subject: [PATCH 07/17] refactor: remove demo main.go and simplify health check examples - Deleted the demo main.go file as it was no longer needed. - Updated health check examples to streamline functionality and improve clarity. - Consolidated health check logic for external APIs in the custom health check example. --- _examples/demo/main.go | 173 ---------------------- _examples/health-check-custom/main.go | 112 +++++--------- _examples/health-check-postgresql/main.go | 10 -- 3 files changed, 35 insertions(+), 260 deletions(-) delete mode 100644 _examples/demo/main.go diff --git a/_examples/demo/main.go b/_examples/demo/main.go deleted file mode 100644 index 11932ee..0000000 --- a/_examples/demo/main.go +++ /dev/null @@ -1,173 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log/slog" - "net/http" - "os" - "time" - - "atomicgo.dev/service" - "github.com/hellofresh/health-go/v5" -) - -func main() { - // Load configuration from environment variables - config, err := service.LoadFromEnv() - if err != nil { - // We can't use svc.Logger here yet, so we'll use slog for this error - slog.Error("failed to load config", "error", err) - os.Exit(1) - } - - // Create service with loaded configuration - svc := service.New("example", config) - - // Add custom middleware - svc.Use(func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Service", "example") - next.ServeHTTP(w, r) - }) - }) - - // Register health checks - svc.RegisterHealthCheck(health.Config{ - Name: "database", - Timeout: time.Second * 5, - SkipOnErr: false, // This check is critical - Check: func(ctx context.Context) error { - // Simulate database health check - // In a real application, you would check your database connection - slog.Info("checking database health") - time.Sleep(100 * time.Millisecond) // Simulate some work - return nil // Return nil for healthy, error for unhealthy - }, - }) - - svc.RegisterHealthCheck(health.Config{ - Name: "cache", - Timeout: time.Second * 3, - SkipOnErr: true, // This check is optional - Check: func(ctx context.Context) error { - // Simulate cache health check - // In a real application, you would check your Redis/Memcached connection - slog.Info("checking cache health") - time.Sleep(50 * time.Millisecond) // Simulate some work - return nil // Return nil for healthy, error for unhealthy - }, - }) - - svc.RegisterHealthCheck(health.Config{ - Name: "external-api", - Timeout: time.Second * 10, - SkipOnErr: true, // External dependencies are often optional - Check: func(ctx context.Context) error { - // Simulate external API health check - slog.Info("checking external API health") - - // Create a simple HTTP request to check external service - client := &http.Client{Timeout: 5 * time.Second} - req, err := http.NewRequestWithContext(ctx, "GET", "https://httb.dev/status/200", nil) - if err != nil { - return err - } - - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("external API returned status %d", resp.StatusCode) - } - - return nil - }, - }) - - // Add shutdown hook to demonstrate graceful shutdown - svc.AddShutdownHook(func() error { - slog.Info("cleaning up resources...") - time.Sleep(1 * time.Second) // Simulate cleanup - slog.Info("cleanup complete") - return nil - }) - - // Register handlers - svc.HandleFunc("/", handleHelloWorld) - svc.HandleFunc("/health-demo", handleHealthDemo) - svc.HandleFunc("/metrics-demo", handleMetricsDemo) - - // Start service with graceful shutdown - svc.Logger.Info("starting service with graceful shutdown support") - svc.Logger.Info("health endpoints available at:") - svc.Logger.Info(" - http://localhost:9090/health (comprehensive health check)") - svc.Logger.Info(" - http://localhost:9090/ready (readiness probe)") - svc.Logger.Info(" - http://localhost:9090/live (liveness probe)") - svc.Logger.Info(" - http://localhost:9090/metrics (prometheus metrics)") - - if err := svc.Start(); err != nil { - svc.Logger.Error("failed to start service", "error", err) - os.Exit(1) - } - - svc.Logger.Info("service stopped") -} - -func handleHelloWorld(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - logger.Info("Hello, World! endpoint called") - - w.WriteHeader(http.StatusOK) - w.Write([]byte("Hello, World!")) -} - -func handleHealthDemo(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - logger.Info("health demo endpoint called") - - // Access the health checker from the request context - healthChecker := service.GetHealthChecker(r) - if healthChecker == nil { - w.WriteHeader(http.StatusServiceUnavailable) - w.Write([]byte("Health checker not available")) - return - } - - // Get current health status - check := healthChecker.Measure(r.Context()) - - // Return health status information - w.Header().Set("Content-Type", "application/json") - if check.Status == "OK" { - w.WriteHeader(http.StatusOK) - } else { - w.WriteHeader(http.StatusServiceUnavailable) - } - - // In a real application, you might want to use json.Marshal - response := fmt.Sprintf(`{ - "status": "%s", - "timestamp": "%s", - "component": { - "name": "%s", - "version": "%s" - } - }`, check.Status, check.Timestamp.Format(time.RFC3339), check.Component.Name, check.Component.Version) - - w.Write([]byte(response)) -} - -func handleMetricsDemo(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - logger.Info("metrics demo endpoint called") - - // Simulate some work that might be measured - time.Sleep(100 * time.Millisecond) - - w.WriteHeader(http.StatusOK) - w.Write([]byte("Metrics demo - check /metrics endpoint for Prometheus metrics")) -} diff --git a/_examples/health-check-custom/main.go b/_examples/health-check-custom/main.go index e6958b2..9a0f263 100644 --- a/_examples/health-check-custom/main.go +++ b/_examples/health-check-custom/main.go @@ -5,107 +5,65 @@ import ( "fmt" "net/http" "os" - "sync/atomic" "time" "atomicgo.dev/service" "github.com/hellofresh/health-go/v5" ) -var ( - // Simulate application state - isReady int64 = 0 - startTime = time.Now() - requestCount int64 -) - func main() { svc := service.New("custom-health-service", nil) - // Custom readiness check - simulates application warm-up + // go-health provides an http health check, but for this example we'll build our own + // This health check will pass, as the external API is reachable svc.RegisterHealthCheck(health.Config{ - Name: "readiness", - Timeout: time.Second * 3, - SkipOnErr: false, - Check: func(ctx context.Context) error { - if atomic.LoadInt64(&isReady) == 0 { - return fmt.Errorf("application is still warming up") - } - return nil - }, + Name: "external-api", + Timeout: time.Second * 5, + Check: checkExternalAPI("https://httb.dev/status/200"), }) - // Custom uptime check + // This will fail, as the external API is not reachable svc.RegisterHealthCheck(health.Config{ - Name: "uptime", - Timeout: time.Second * 1, - SkipOnErr: true, // Non-critical check - Check: func(ctx context.Context) error { - uptime := time.Since(startTime) - if uptime < 10*time.Second { - return fmt.Errorf("service recently started (uptime: %v)", uptime) - } - return nil - }, - }) - - // Custom external dependency check - svc.RegisterHealthCheck(health.Config{ - Name: "external-api", - Timeout: time.Second * 5, - SkipOnErr: true, // External dependencies are often optional - Check: func(ctx context.Context) error { - // Check external service availability - client := &http.Client{Timeout: 3 * time.Second} - req, err := http.NewRequestWithContext(ctx, "GET", "https://httpbin.org/status/200", nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("external API unreachable: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("external API returned status %d", resp.StatusCode) - } - - return nil - }, + Name: "external-api-failing", + Timeout: time.Second * 5, + Check: checkExternalAPI("https://httb.dev/status/404"), }) // Main handler svc.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - logger.Info("Request received") - - // Increment request counter - atomic.AddInt64(&requestCount, 1) - - w.Write([]byte("Custom Health Check Service\n")) - w.Write([]byte(fmt.Sprintf("Uptime: %v\n", time.Since(startTime)))) - w.Write([]byte(fmt.Sprintf("Request count: %d\n", atomic.LoadInt64(&requestCount)))) - w.Write([]byte("Health check at: /health\n")) + w.Write([]byte("Hello, World!")) }) - // Simulate application warm-up - go func() { - svc.Logger.Info("Starting application warm-up...") - time.Sleep(5 * time.Second) - atomic.StoreInt64(&isReady, 1) - svc.Logger.Info("Application warm-up complete, now ready") - }() - - svc.Logger.Info("Starting custom health check service...") svc.Logger.Info("Service available at http://localhost:8080") svc.Logger.Info("Health check at http://localhost:9090/health") - svc.Logger.Info("Toggle readiness with: POST http://localhost:8080/ready") - svc.Logger.Info("View stats at http://localhost:8080/stats") if err := svc.Start(); err != nil { svc.Logger.Error("Failed to start service", "error", err) os.Exit(1) } } + +// Custom health check function that checks if the external API is reachable +func checkExternalAPI(url string) health.CheckFunc { + return func(ctx context.Context) error { + client := &http.Client{Timeout: 3 * time.Second} + + // Replace with a non-existent URL to simulate a failed health check + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("external API unreachable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("external API returned status %d", resp.StatusCode) + } + + return nil + } +} diff --git a/_examples/health-check-postgresql/main.go b/_examples/health-check-postgresql/main.go index 2da0272..454cc5d 100644 --- a/_examples/health-check-postgresql/main.go +++ b/_examples/health-check-postgresql/main.go @@ -1,7 +1,6 @@ package main import ( - "database/sql" "net/http" "os" "time" @@ -32,20 +31,11 @@ func main() { }), }) - // Open database connection for stats endpoint - db, err := sql.Open("postgres", dbURL) - if err != nil { - svc.Logger.Error("Failed to open database connection", "error", err) - os.Exit(1) - } - defer db.Close() - // Simple handler svc.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Hello, World!")) }) - svc.Logger.Info("Starting PostgreSQL health check service...") svc.Logger.Info("Service available at http://localhost:8080") svc.Logger.Info("Health check at http://localhost:9090/health") svc.Logger.Info("Database URL: " + dbURL) From 96e68cc84232507aeaa4f39eb818caa7b08027bc Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Sat, 12 Jul 2025 00:16:05 +0200 Subject: [PATCH 08/17] fix: correct import alias for PostgreSQL health check in README - Updated the import alias for the PostgreSQL health check from `healthMysql` to `healthPostgres` in the README example to reflect accurate naming conventions. --- README.md | 2 +- _examples/health-check-access/main.go | 60 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 _examples/health-check-access/main.go diff --git a/README.md b/README.md index 78a35a3..741a452 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ The health-go library provides several built-in health checkers for common servi ```go import ( "github.com/hellofresh/health-go/v5" - healthMysql "github.com/hellofresh/health-go/v5/checks/postgres" + healthPostgres "github.com/hellofresh/health-go/v5/checks/postgres" healthRedis "github.com/hellofresh/health-go/v5/checks/redis" ) diff --git a/_examples/health-check-access/main.go b/_examples/health-check-access/main.go new file mode 100644 index 0000000..67e835e --- /dev/null +++ b/_examples/health-check-access/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "time" + + "atomicgo.dev/service" + "github.com/hellofresh/health-go/v5" + healthHttp "github.com/hellofresh/health-go/v5/checks/http" + _ "github.com/lib/pq" +) + +func main() { + // Create service + svc := service.New("accessing-health-checker-from-handlers", nil) + + // Register external API health check using built-in checker + svc.RegisterHealthCheck(health.Config{ + Name: "external-api-should-success", + Timeout: time.Second * 5, + SkipOnErr: false, + Check: healthHttp.New(healthHttp.Config{ + URL: "https://httb.dev/status/200", + }), + }) + + svc.RegisterHealthCheck(health.Config{ + Name: "external-api-should-fail", + Timeout: time.Second * 5, + SkipOnErr: false, + Check: healthHttp.New(healthHttp.Config{ + URL: "https://httb.dev/status/503", + }), + }) + + // Simple handler that accesses the health checker + svc.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + healthChecker := service.GetHealthChecker(r) + if healthChecker != nil { + check := healthChecker.Measure(r.Context()) + // Pretty print the check + json, err := json.MarshalIndent(check, "", " ") + if err != nil { + w.Write([]byte(fmt.Sprintf("Error marshalling check: %v", err))) + } + w.Write(json) + } + }) + + svc.Logger.Info("Service available at http://localhost:8080") + svc.Logger.Info("Health check at http://localhost:9090/health") + + if err := svc.Start(); err != nil { + svc.Logger.Error("Failed to start service", "error", err) + os.Exit(1) + } +} From 8595a970a2bcb1ae0587fa4360f842889b4581ab Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Sat, 12 Jul 2025 00:46:49 +0200 Subject: [PATCH 09/17] fix: update health check example in README to improve clarity - Simplified the health check example in the README by directly using the result of the Measure function. - Updated the response to include health check details for better visibility. --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 741a452..f22bb97 100644 --- a/README.md +++ b/README.md @@ -326,11 +326,9 @@ You can access the health checker in your HTTP handlers: func myHandler(w http.ResponseWriter, r *http.Request) { healthChecker := service.GetHealthChecker(r) if healthChecker != nil { - status, err := healthChecker.Measure(r.Context()) - if err != nil { - // Handle error - } + check := healthChecker.Measure(r.Context()) // Use status information + w.Write([]byte(fmt.Sprintf("Health checks: %+v", check))) } } ``` From c62d8285069642e508f5d5a1af18dd3ab572e1f6 Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Sat, 12 Jul 2025 01:04:32 +0200 Subject: [PATCH 10/17] chore: update golangci-lint configuration --- .golangci.yml | 227 +++++++++++++++++++++++++------------------------- 1 file changed, 114 insertions(+), 113 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 86989d1..c7a6b91 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,115 +1,116 @@ -# ┌───────────────────────────────────────────────────────────────────┐ -# │ │ -# │ IMPORTANT NOTE │ -# │ │ -# │ This file is synced with https://github.com/atomicgo/template │ -# │ │ -# │ Please apply all changes to the template repository │ -# │ │ -# └───────────────────────────────────────────────────────────────────┘ - -run: - timeout: 3m - +version: "2" linters: - enable-all: true + default: all disable: - - copyloopvar # fixed in go 1.22+ - - depguard # no forbidden imports - - dogsled # blank identifiers are allowed - - dupword # duplicate words are allowed - - exhaustruct # many structs don't need to be exhaustive - - forbidigo # no forbidden identifiers - - ginkgolinter # not used - - gochecknoinits # init functions are fine, if used carefully - - goconst # many false positives - - godot # comments don't need to be complete sentences - - godox # todo comments are allowed - - goheader # no need for a header - - gomoddirectives # allow all directives - - gomodguard # no forbidden imports - - grouper # unused - - importas # some aliases are fine - - makezero # make with non-zero initial length is fine - - noctx # http request may be sent without context - - nonamedreturns # named returns are fine - - testableexamples # examples do not need to be testable (have declared output) - - testifylint # testify is not recommended - - testpackage # not a go best practice - - unparam # interfaces can enforce parameters - - zerologlint # slog should be used instead of zerolog - - mnd # too many detections - - cyclop # covered by gocyclo - - gochecknoglobals # there are many valid reasons for global variables, depending on the project - - ireturn # there are too many exceptions - - tenv # deprecated - -linters-settings: - wsl: - allow-cuddle-declarations: true - force-err-cuddling: true - force-case-trailing-whitespace: 3 - - funlen: - lines: 100 - statements: 50 - ignore-comments: true - - lll: - line-length: 140 - tab-width: 1 - - nlreturn: - block-size: 2 - - exhaustive: - check-generated: false - default-signifies-exhaustive: true - - varnamelen: - ignore-type-assert-ok: true # ignore "ok" variables - ignore-map-index-ok: true - ignore-chan-recv-ok: true - ignore-decls: - - n int # generic number - - x int # generic number (e.g. coordinate) - - y int # generic number (e.g. coordinate) - - z int # generic number (e.g. coordinate) - - i int # generic number - - a int # generic number - - r int # generic number (e.g. red or radius) - - g int # generic number (e.g. green) - - b int # generic number (e.g. blue) - - r int64 # generic number (e.g. red or radius) - - g int64 # generic number (e.g. green) - - b int64 # generic number (e.g. blue) - - c int # generic number (e.g. count) - - j int # generic number (e.g. index) - - T any # generic type - - a any # generic any (e.g. data) - - b any # generic any (e.g. body) - - c any # generic any - - d any # generic any (e.g. data) - - data any # generic data - - n any # generic any - - ch chan T # common generic channel name - - ch chan int # common generic channel name - - ch chan any # common generic channel name - - wg sync.WaitGroup # common generic WaitGroup name - - t time.Time # often used as a variable name - - f func() # often used as a callback variable name - - f func(T) # often used as a generic callback variable name - - cb func() # often used as a callback variable name - - t testing.T # default testing.T variable name - - b testing.B # default testing.B variable name - - sb strings.Builder # often used as a variable name - -issues: - exclude-rules: - - path: "_test(_[^/]+)?\\.go" - linters: - - gochecknoglobals - - noctx - - funlen - - dupl - - errcheck + - copyloopvar + - cyclop + - depguard + - dogsled + - dupword + - exhaustruct + - forbidigo + - ginkgolinter + - gochecknoglobals + - gochecknoinits + - goconst + - godot + - godox + - goheader + - gomoddirectives + - gomodguard + - grouper + - importas + - ireturn + - makezero + - mnd + - noctx + - nonamedreturns + - testableexamples + - testifylint + - testpackage + - unparam + - zerologlint + settings: + exhaustive: + default-signifies-exhaustive: true + funlen: + lines: 100 + statements: 50 + ignore-comments: true + lll: + line-length: 140 + tab-width: 1 + nlreturn: + block-size: 2 + varnamelen: + ignore-type-assert-ok: true + ignore-map-index-ok: true + ignore-chan-recv-ok: true + ignore-decls: + - n int + - x int + - y int + - z int + - i int + - a int + - r int + - g int + - b int + - r int64 + - g int64 + - b int64 + - c int + - j int + - T any + - a any + - b any + - c any + - d any + - data any + - n any + - ch chan T + - ch chan int + - ch chan any + - wg sync.WaitGroup + - t time.Time + - f func() + - f func(T) + - cb func() + - t testing.T + - b testing.B + - sb strings.Builder + wsl: + force-case-trailing-whitespace: 3 + allow-cuddle-declarations: true + force-err-cuddling: true + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - dupl + - errcheck + - funlen + - gochecknoglobals + - noctx + path: _test(_[^/]+)?\.go + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ From 4e64c431466f3e199707cd93ca10d5213e0a424f Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Sat, 12 Jul 2025 01:05:08 +0200 Subject: [PATCH 11/17] chore: fix whitespaces --- go.mod | 1 - go.sum | 2 -- health.go | 1 + health_test.go | 4 ++++ metrics.go | 2 ++ middleware.go | 2 ++ service.go | 1 + service_test.go | 7 ++++++- shutdown.go | 4 ++++ 9 files changed, 20 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 54b3b0e..0903c5a 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/lib/pq v1.10.9 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect diff --git a/go.sum b/go.sum index 0899648..7985aa2 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,6 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/health.go b/health.go index 44e81ff..6a0b961 100644 --- a/health.go +++ b/health.go @@ -111,5 +111,6 @@ func GetHealthChecker(r *http.Request) *HealthChecker { if !ok { return nil } + return hc } diff --git a/health_test.go b/health_test.go index 930aac9..7ed4771 100644 --- a/health_test.go +++ b/health_test.go @@ -224,9 +224,11 @@ func TestGetHealthChecker(t *testing.T) { if retrievedHC == nil { t.Error("expected health checker to be retrieved from context") } + if retrievedHC != hc { t.Error("expected retrieved health checker to match original") } + w.WriteHeader(http.StatusOK) })) @@ -264,6 +266,7 @@ func TestHealthCheckerMiddleware(t *testing.T) { if retrievedHC == nil { t.Error("health checker should be available in context") } + w.WriteHeader(http.StatusOK) })) @@ -308,6 +311,7 @@ func TestService_RegisterHealthCheck(t *testing.T) { t.Run("registers health check successfully", func(t *testing.T) { checkCalled := false + svc.RegisterHealthCheck(health.Config{ Name: "test-check", Check: func(ctx context.Context) error { diff --git a/metrics.go b/metrics.go index e26d2ce..f96218b 100644 --- a/metrics.go +++ b/metrics.go @@ -108,6 +108,7 @@ func GetMetrics(r *http.Request) *MetricsCollector { if !ok { return nil } + return metrics } @@ -180,5 +181,6 @@ func (s *Service) startMetricsServer() error { } s.Logger.Info("starting metrics server", "addr", s.Config.MetricsAddr, "path", s.Config.MetricsPath) + return s.metricsServer.ListenAndServe() } diff --git a/middleware.go b/middleware.go index cf1e009..14fb8bf 100644 --- a/middleware.go +++ b/middleware.go @@ -44,6 +44,7 @@ func GetLogger(r *http.Request) *slog.Logger { // Return a default logger if none is found return slog.Default() } + return logger } @@ -97,5 +98,6 @@ func applyMiddleware(h http.Handler, middlewares ...Middleware) http.Handler { for i := len(middlewares) - 1; i >= 0; i-- { h = middlewares[i](h) } + return h } diff --git a/service.go b/service.go index 88367a1..5fb5438 100644 --- a/service.go +++ b/service.go @@ -113,6 +113,7 @@ func (s *Service) Start() error { } s.Logger.Info("starting service", "name", s.Name, "addr", s.Config.Addr) + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { s.Logger.Error("server error", "error", err) serverErrors <- err diff --git a/service_test.go b/service_test.go index c53f8eb..51e8055 100644 --- a/service_test.go +++ b/service_test.go @@ -75,6 +75,7 @@ func TestLoadFromEnv(t *testing.T) { os.Setenv("ADDR", ":8888") os.Setenv("METRICS_ADDR", ":9999") os.Setenv("METRICS_PATH", "/custom-metrics") + defer func() { os.Unsetenv("ADDR") os.Unsetenv("METRICS_ADDR") @@ -133,6 +134,7 @@ func TestGetLogger(t *testing.T) { if logger == nil { t.Error("logger should not be nil") } + w.WriteHeader(http.StatusOK) }), LoggerMiddleware(svc.Logger)) @@ -155,6 +157,7 @@ func TestGetMetrics(t *testing.T) { if metrics == nil { t.Error("metrics should not be nil") } + w.WriteHeader(http.StatusOK) }), MetricsMiddleware(svc.Metrics)) @@ -207,7 +210,6 @@ func TestMetricsMiddleware(t *testing.T) { if recorder.Code != http.StatusOK { t.Errorf("expected status 200, got %d", recorder.Code) } - // Note: In a real test, you'd check if the metrics were actually recorded // This would require exposing the metrics or using a metrics registry } @@ -216,6 +218,7 @@ func TestShutdownHooks(t *testing.T) { svc := New("test", nil) hookCalled := false + svc.AddShutdownHook(func() error { hookCalled = true return nil @@ -332,6 +335,7 @@ func BenchmarkHandleFunc(b *testing.B) { req := httptest.NewRequest("GET", "/benchmark", nil) b.ResetTimer() + for i := 0; i < b.N; i++ { recorder := httptest.NewRecorder() svc.mux.ServeHTTP(recorder, req) @@ -351,6 +355,7 @@ func BenchmarkMiddleware(b *testing.B) { req := httptest.NewRequest("GET", "/benchmark", nil) b.ResetTimer() + for i := 0; i < b.N; i++ { recorder := httptest.NewRecorder() wrappedHandler.ServeHTTP(recorder, req) diff --git a/shutdown.go b/shutdown.go index a99075d..0ddd2d6 100644 --- a/shutdown.go +++ b/shutdown.go @@ -15,6 +15,7 @@ func (s *Service) gracefulShutdown() error { // Execute shutdown hooks for i, hook := range s.Config.ShutdownHooks { s.Logger.Info("executing shutdown hook", "index", i) + if err := hook(); err != nil { s.Logger.Error("shutdown hook failed", "index", i, "error", err) // Continue with other hooks even if one fails @@ -27,6 +28,7 @@ func (s *Service) gracefulShutdown() error { // Shutdown main HTTP server if s.server != nil { s.Logger.Info("shutting down HTTP server") + if err := s.server.Shutdown(ctx); err != nil { s.Logger.Error("HTTP server shutdown error", "error", err) shutdownErrors = append(shutdownErrors, err) @@ -36,6 +38,7 @@ func (s *Service) gracefulShutdown() error { // Shutdown metrics server if s.metricsServer != nil { s.Logger.Info("shutting down metrics server") + if err := s.metricsServer.Shutdown(ctx); err != nil { s.Logger.Error("metrics server shutdown error", "error", err) shutdownErrors = append(shutdownErrors, err) @@ -48,6 +51,7 @@ func (s *Service) gracefulShutdown() error { } s.Logger.Info("graceful shutdown completed") + return nil } From 23c1f8dee45a4e328d9700eecd1842b004f34bab Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Sat, 12 Jul 2025 01:38:59 +0200 Subject: [PATCH 12/17] chore: fix most linting issues --- .golangci.yml | 6 ++ config.go | 13 +++-- health.go | 15 ++--- health_test.go | 144 ++++++++++++++++++++++++++++++++---------------- metrics.go | 32 ++++++----- service.go | 17 ++++-- service_test.go | 75 +++++++++++++++---------- shutdown.go | 1 - 8 files changed, 194 insertions(+), 109 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index c7a6b91..a0ff5cd 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -30,6 +30,7 @@ linters: - testpackage - unparam - zerologlint + - noinlineerr settings: exhaustive: default-signifies-exhaustive: true @@ -79,6 +80,8 @@ linters: - t testing.T - b testing.B - sb strings.Builder + - w http.ResponseWriter + - r *http.Request wsl: force-case-trailing-whitespace: 3 allow-cuddle-declarations: true @@ -98,6 +101,9 @@ linters: - gochecknoglobals - noctx path: _test(_[^/]+)?\.go + - linters: + - revive + text: "unused-parameter:" paths: - third_party$ - builtin$ diff --git a/config.go b/config.go index 87d821a..d5c42b8 100644 --- a/config.go +++ b/config.go @@ -1,6 +1,7 @@ package service import ( + "fmt" "log/slog" "os" "time" @@ -11,10 +12,10 @@ import ( // Config holds all configuration for the service type Config struct { // HTTP Server configuration - Addr string `env:"ADDR" envDefault:":8080"` - ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"10s"` + Addr string `env:"ADDR" envDefault:":8080"` + ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"10s"` WriteTimeout time.Duration `env:"WRITE_TIMEOUT" envDefault:"10s"` - IdleTimeout time.Duration `env:"IDLE_TIMEOUT" envDefault:"120s"` + IdleTimeout time.Duration `env:"IDLE_TIMEOUT" envDefault:"120s"` // Metrics server configuration MetricsAddr string `env:"METRICS_ADDR" envDefault:":9090"` @@ -27,9 +28,9 @@ type Config struct { Version string `env:"SERVICE_VERSION" envDefault:"v1.0.0"` // Health check configuration - HealthPath string `env:"HEALTH_PATH" envDefault:"/health"` + HealthPath string `env:"HEALTH_PATH" envDefault:"/health"` ReadinessPath string `env:"READINESS_PATH" envDefault:"/ready"` - LivenessPath string `env:"LIVENESS_PATH" envDefault:"/live"` + LivenessPath string `env:"LIVENESS_PATH" envDefault:"/live"` // Logger configuration Logger *slog.Logger `env:"-"` @@ -62,7 +63,7 @@ func LoadFromEnv() (*Config, error) { config := DefaultConfig() if err := env.Parse(config); err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse environment variables: %w", err) } return config, nil diff --git a/health.go b/health.go index 6a0b961..2fe8c2f 100644 --- a/health.go +++ b/health.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "net/http" "time" @@ -22,7 +23,7 @@ func NewHealthChecker(serviceName, version string) (*HealthChecker, error) { }), ) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create health checker: %w", err) } return &HealthChecker{ @@ -31,8 +32,8 @@ func NewHealthChecker(serviceName, version string) (*HealthChecker, error) { } // Register adds a health check to the health checker -func (hc *HealthChecker) Register(config health.Config) { - hc.checker.Register(config) +func (hc *HealthChecker) Register(config health.Config) error { + return fmt.Errorf("failed to register health check: %w", hc.checker.Register(config)) } // Handler returns the HTTP handler for health checks @@ -81,10 +82,10 @@ func (hc *HealthChecker) ReadinessHandler() http.HandlerFunc { if hc.IsReady(ctx) { w.WriteHeader(http.StatusOK) - w.Write([]byte("Ready")) + _, _ = w.Write([]byte("Ready")) } else { w.WriteHeader(http.StatusServiceUnavailable) - w.Write([]byte("Not Ready")) + _, _ = w.Write([]byte("Not Ready")) } } } @@ -97,10 +98,10 @@ func (hc *HealthChecker) LivenessHandler() http.HandlerFunc { if hc.IsAlive(ctx) { w.WriteHeader(http.StatusOK) - w.Write([]byte("Alive")) + _, _ = w.Write([]byte("Alive")) } else { w.WriteHeader(http.StatusServiceUnavailable) - w.Write([]byte("Not Alive")) + _, _ = w.Write([]byte("Not Alive")) } } } diff --git a/health_test.go b/health_test.go index 7ed4771..1141a0f 100644 --- a/health_test.go +++ b/health_test.go @@ -12,30 +12,36 @@ import ( ) func TestNewHealthChecker(t *testing.T) { + t.Parallel() + t.Run("creates health checker successfully", func(t *testing.T) { - hc, err := NewHealthChecker("test-service", "v1.0.0") + t.Parallel() + + healthChecker, err := NewHealthChecker("test-service", "v1.0.0") if err != nil { t.Fatalf("expected no error, got %v", err) } - if hc == nil { + if healthChecker == nil { t.Fatal("expected health checker to be created") } - if hc.checker == nil { + if healthChecker.checker == nil { t.Fatal("expected internal health checker to be initialized") } }) } func TestHealthChecker_Register(t *testing.T) { - hc, err := NewHealthChecker("test-service", "v1.0.0") + t.Parallel() + + healthChecker, err := NewHealthChecker("test-service", "v1.0.0") if err != nil { t.Fatalf("failed to create health checker: %v", err) } // Register a simple health check - hc.Register(health.Config{ + healthChecker.Register(health.Config{ Name: "test-check", Check: func(ctx context.Context) error { return nil @@ -43,7 +49,7 @@ func TestHealthChecker_Register(t *testing.T) { }) // Verify health check was registered by measuring health - check := hc.Measure(context.Background()) + check := healthChecker.Measure(context.Background()) if check.Status != health.StatusOK { t.Errorf("expected status OK, got %s", check.Status) @@ -51,78 +57,92 @@ func TestHealthChecker_Register(t *testing.T) { } func TestHealthChecker_IsHealthy(t *testing.T) { - hc, err := NewHealthChecker("test-service", "v1.0.0") + t.Parallel() + + healthChecker, err := NewHealthChecker("test-service", "v1.0.0") if err != nil { t.Fatalf("failed to create health checker: %v", err) } t.Run("returns true when all checks pass", func(t *testing.T) { - hc.Register(health.Config{ + t.Parallel() + + healthChecker.Register(health.Config{ Name: "passing-check", - Check: func(ctx context.Context) error { + Check: func(_ context.Context) error { return nil }, }) - if !hc.IsHealthy(context.Background()) { + if !healthChecker.IsHealthy(context.Background()) { t.Error("expected IsHealthy to return true") } }) t.Run("returns false when check fails", func(t *testing.T) { - hc.Register(health.Config{ + t.Parallel() + + healthChecker.Register(health.Config{ Name: "failing-check", Check: func(ctx context.Context) error { - return errors.New("check failed") + return errors.New("check failed") //nolint:err113 }, }) - if hc.IsHealthy(context.Background()) { + if healthChecker.IsHealthy(context.Background()) { t.Error("expected IsHealthy to return false") } }) } func TestHealthChecker_IsReady(t *testing.T) { - hc, err := NewHealthChecker("test-service", "v1.0.0") + t.Parallel() + + healthChecker, err := NewHealthChecker("test-service", "v1.0.0") if err != nil { t.Fatalf("failed to create health checker: %v", err) } t.Run("returns true when healthy", func(t *testing.T) { - hc.Register(health.Config{ + t.Parallel() + + healthChecker.Register(health.Config{ Name: "ready-check", Check: func(ctx context.Context) error { return nil }, }) - if !hc.IsReady(context.Background()) { + if !healthChecker.IsReady(context.Background()) { t.Error("expected IsReady to return true") } }) } func TestHealthChecker_IsAlive(t *testing.T) { - hc, err := NewHealthChecker("test-service", "v1.0.0") + t.Parallel() + + healthChecker, err := NewHealthChecker("test-service", "v1.0.0") if err != nil { t.Fatalf("failed to create health checker: %v", err) } // IsAlive should always return true for a running service - if !hc.IsAlive(context.Background()) { + if !healthChecker.IsAlive(context.Background()) { t.Error("expected IsAlive to return true") } } func TestHealthChecker_Handlers(t *testing.T) { - hc, err := NewHealthChecker("test-service", "v1.0.0") + t.Parallel() + + healthChecker, err := NewHealthChecker("test-service", "v1.0.0") if err != nil { t.Fatalf("failed to create health checker: %v", err) } // Register a health check - hc.Register(health.Config{ + healthChecker.Register(health.Config{ Name: "test-check", Check: func(ctx context.Context) error { return nil @@ -130,10 +150,12 @@ func TestHealthChecker_Handlers(t *testing.T) { }) t.Run("Handler returns 200 for healthy service", func(t *testing.T) { - req := httptest.NewRequest("GET", "/health", nil) + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/health", nil) recorder := httptest.NewRecorder() - hc.Handler().ServeHTTP(recorder, req) + healthChecker.Handler().ServeHTTP(recorder, req) if recorder.Code != http.StatusOK { t.Errorf("expected status 200, got %d", recorder.Code) @@ -141,10 +163,12 @@ func TestHealthChecker_Handlers(t *testing.T) { }) t.Run("HandlerFunc returns 200 for healthy service", func(t *testing.T) { - req := httptest.NewRequest("GET", "/health", nil) + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/health", nil) recorder := httptest.NewRecorder() - hc.HandlerFunc(recorder, req) + healthChecker.HandlerFunc(recorder, req) if recorder.Code != http.StatusOK { t.Errorf("expected status 200, got %d", recorder.Code) @@ -152,10 +176,12 @@ func TestHealthChecker_Handlers(t *testing.T) { }) t.Run("ReadinessHandler returns 200 for ready service", func(t *testing.T) { - req := httptest.NewRequest("GET", "/ready", nil) + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/ready", nil) recorder := httptest.NewRecorder() - hc.ReadinessHandler()(recorder, req) + healthChecker.ReadinessHandler()(recorder, req) if recorder.Code != http.StatusOK { t.Errorf("expected status 200, got %d", recorder.Code) @@ -167,10 +193,12 @@ func TestHealthChecker_Handlers(t *testing.T) { }) t.Run("LivenessHandler returns 200 for alive service", func(t *testing.T) { - req := httptest.NewRequest("GET", "/live", nil) + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/live", nil) recorder := httptest.NewRecorder() - hc.LivenessHandler()(recorder, req) + healthChecker.LivenessHandler()(recorder, req) if recorder.Code != http.StatusOK { t.Errorf("expected status 200, got %d", recorder.Code) @@ -183,24 +211,28 @@ func TestHealthChecker_Handlers(t *testing.T) { } func TestHealthChecker_HandlersWithFailures(t *testing.T) { - hc, err := NewHealthChecker("test-service", "v1.0.0") + t.Parallel() + + healthChecker, err := NewHealthChecker("test-service", "v1.0.0") if err != nil { t.Fatalf("failed to create health checker: %v", err) } // Register a failing health check - hc.Register(health.Config{ + healthChecker.Register(health.Config{ Name: "failing-check", Check: func(ctx context.Context) error { - return errors.New("check failed") + return errors.New("check failed") //nolint:err113 }, }) t.Run("ReadinessHandler returns 503 for not ready service", func(t *testing.T) { - req := httptest.NewRequest("GET", "/ready", nil) + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/ready", nil) recorder := httptest.NewRecorder() - hc.ReadinessHandler()(recorder, req) + healthChecker.ReadinessHandler()(recorder, req) if recorder.Code != http.StatusServiceUnavailable { t.Errorf("expected status 503, got %d", recorder.Code) @@ -213,26 +245,30 @@ func TestHealthChecker_HandlersWithFailures(t *testing.T) { } func TestGetHealthChecker(t *testing.T) { - hc, err := NewHealthChecker("test-service", "v1.0.0") + t.Parallel() + + healthChecker, err := NewHealthChecker("test-service", "v1.0.0") if err != nil { t.Fatalf("failed to create health checker: %v", err) } t.Run("returns health checker from context", func(t *testing.T) { - handler := HealthCheckerMiddleware(hc)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Parallel() + + handler := HealthCheckerMiddleware(healthChecker)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { retrievedHC := GetHealthChecker(r) if retrievedHC == nil { t.Error("expected health checker to be retrieved from context") } - if retrievedHC != hc { + if retrievedHC != healthChecker { t.Error("expected retrieved health checker to match original") } w.WriteHeader(http.StatusOK) })) - req := httptest.NewRequest("GET", "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", nil) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) @@ -243,7 +279,9 @@ func TestGetHealthChecker(t *testing.T) { }) t.Run("returns nil when not in context", func(t *testing.T) { - req := httptest.NewRequest("GET", "/test", nil) + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/test", nil) retrievedHC := GetHealthChecker(req) if retrievedHC != nil { @@ -253,12 +291,14 @@ func TestGetHealthChecker(t *testing.T) { } func TestHealthCheckerMiddleware(t *testing.T) { - hc, err := NewHealthChecker("test-service", "v1.0.0") + t.Parallel() + + healthChecker, err := NewHealthChecker("test-service", "v1.0.0") if err != nil { t.Fatalf("failed to create health checker: %v", err) } - middleware := HealthCheckerMiddleware(hc) + middleware := HealthCheckerMiddleware(healthChecker) handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify health checker is available in context @@ -270,7 +310,7 @@ func TestHealthCheckerMiddleware(t *testing.T) { w.WriteHeader(http.StatusOK) })) - req := httptest.NewRequest("GET", "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", nil) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) @@ -281,13 +321,15 @@ func TestHealthCheckerMiddleware(t *testing.T) { } func TestHealthChecker_Timeout(t *testing.T) { - hc, err := NewHealthChecker("test-service", "v1.0.0") + t.Parallel() + + healthChecker, err := NewHealthChecker("test-service", "v1.0.0") if err != nil { t.Fatalf("failed to create health checker: %v", err) } // Register a health check with a timeout - hc.Register(health.Config{ + healthChecker.Register(health.Config{ Name: "slow-check", Timeout: 100 * time.Millisecond, Check: func(ctx context.Context) error { @@ -301,15 +343,19 @@ func TestHealthChecker_Timeout(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() - if hc.IsHealthy(ctx) { + if healthChecker.IsHealthy(ctx) { t.Error("expected health check to fail due to timeout") } } func TestService_RegisterHealthCheck(t *testing.T) { + t.Parallel() + svc := New("test-service", nil) t.Run("registers health check successfully", func(t *testing.T) { + t.Parallel() + checkCalled := false svc.RegisterHealthCheck(health.Config{ @@ -335,6 +381,8 @@ func TestService_RegisterHealthCheck(t *testing.T) { }) t.Run("handles nil health checker gracefully", func(t *testing.T) { + t.Parallel() + svcWithoutHealth := &Service{ Name: "test", Logger: svc.Logger, @@ -352,14 +400,16 @@ func TestService_RegisterHealthCheck(t *testing.T) { } func TestService_GetHealthChecker(t *testing.T) { + t.Parallel() + svc := New("test-service", nil) - hc := svc.GetHealthChecker() - if hc == nil { + healthChecker := svc.GetHealthChecker() + if healthChecker == nil { t.Error("expected health checker to be available") } - if hc != svc.HealthChecker { + if healthChecker != svc.HealthChecker { t.Error("expected returned health checker to match service health checker") } } diff --git a/metrics.go b/metrics.go index f96218b..e44ecd7 100644 --- a/metrics.go +++ b/metrics.go @@ -19,7 +19,7 @@ type MetricsCollector struct { // NewMetricsCollector creates a new metrics collector func NewMetricsCollector(serviceName string) *MetricsCollector { - mc := &MetricsCollector{ + metricsCollector := &MetricsCollector{ httpRequestsTotal: prometheus.NewCounterVec( prometheus.CounterOpts{ Name: serviceName + "_http_requests_total", @@ -44,16 +44,17 @@ func NewMetricsCollector(serviceName string) *MetricsCollector { } // Register metrics with Prometheus (ignore if already registered) - prometheus.DefaultRegisterer.Register(mc.httpRequestsTotal) - prometheus.DefaultRegisterer.Register(mc.httpRequestDuration) - prometheus.DefaultRegisterer.Register(mc.httpRequestsInFlight) + _ = prometheus.DefaultRegisterer.Register(metricsCollector.httpRequestsTotal) + _ = prometheus.DefaultRegisterer.Register(metricsCollector.httpRequestDuration) + _ = prometheus.DefaultRegisterer.Register(metricsCollector.httpRequestsInFlight) - return mc + return metricsCollector } // responseWriter wraps http.ResponseWriter to capture status code type responseWriter struct { http.ResponseWriter + statusCode int } @@ -161,26 +162,29 @@ func (s *Service) startMetricsServer() error { mux.HandleFunc(s.Config.LivenessPath, s.HealthChecker.LivenessHandler()) } else { // Fallback basic health endpoints if health checker is not available - mux.HandleFunc(s.Config.HealthPath, func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc(s.Config.HealthPath, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) + _, _ = w.Write([]byte("OK")) }) - mux.HandleFunc(s.Config.ReadinessPath, func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc(s.Config.ReadinessPath, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte("Ready")) + _, _ = w.Write([]byte("Ready")) }) - mux.HandleFunc(s.Config.LivenessPath, func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc(s.Config.LivenessPath, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte("Alive")) + _, _ = w.Write([]byte("Alive")) }) } s.metricsServer = &http.Server{ - Addr: s.Config.MetricsAddr, - Handler: mux, + Addr: s.Config.MetricsAddr, + Handler: mux, + ReadTimeout: 5 * time.Minute, + WriteTimeout: 5 * time.Minute, + IdleTimeout: 5 * time.Minute, } s.Logger.Info("starting metrics server", "addr", s.Config.MetricsAddr, "path", s.Config.MetricsPath) - return s.metricsServer.ListenAndServe() + return s.metricsServer.ListenAndServe() //nolint:wrapcheck } diff --git a/service.go b/service.go index 5fb5438..e7d3a3d 100644 --- a/service.go +++ b/service.go @@ -1,6 +1,7 @@ package service import ( + "errors" "log/slog" "net/http" "os" @@ -96,8 +97,9 @@ func (s *Service) Start() error { // Start metrics server go func() { - if err := s.startMetricsServer(); err != nil && err != http.ErrServerClosed { + if err := s.startMetricsServer(); err != nil && !errors.Is(err, http.ErrServerClosed) { s.Logger.Error("metrics server error", "error", err) + serverErrors <- err } }() @@ -114,8 +116,9 @@ func (s *Service) Start() error { s.Logger.Info("starting service", "name", s.Name, "addr", s.Config.Addr) - if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { s.Logger.Error("server error", "error", err) + serverErrors <- err } }() @@ -134,12 +137,14 @@ func (s *Service) Start() error { } // RegisterHealthCheck adds a health check to the service -func (s *Service) RegisterHealthCheck(config health.Config) { +func (s *Service) RegisterHealthCheck(config health.Config) error { if s.HealthChecker != nil { - s.HealthChecker.Register(config) - } else { - s.Logger.Warn("health checker not available, skipping health check registration", "name", config.Name) + return s.HealthChecker.Register(config) } + + s.Logger.Warn("health checker not available, skipping health check registration", "name", config.Name) + + return nil } // GetHealthChecker returns the health checker instance diff --git a/service_test.go b/service_test.go index 51e8055..fc88404 100644 --- a/service_test.go +++ b/service_test.go @@ -3,14 +3,17 @@ package service import ( "net/http" "net/http/httptest" - "os" "strings" "testing" "time" ) func TestNew(t *testing.T) { + t.Parallel() + t.Run("with config", func(t *testing.T) { + t.Parallel() + config := DefaultConfig() config.Addr = ":8081" @@ -34,6 +37,8 @@ func TestNew(t *testing.T) { }) t.Run("with nil config", func(t *testing.T) { + t.Parallel() + svc := New("test", nil) if svc.Config == nil { @@ -47,6 +52,8 @@ func TestNew(t *testing.T) { } func TestDefaultConfig(t *testing.T) { + t.Parallel() + config := DefaultConfig() if config.Addr != ":8080" { @@ -71,16 +78,12 @@ func TestDefaultConfig(t *testing.T) { } func TestLoadFromEnv(t *testing.T) { - // Set environment variables - os.Setenv("ADDR", ":8888") - os.Setenv("METRICS_ADDR", ":9999") - os.Setenv("METRICS_PATH", "/custom-metrics") + t.Parallel() - defer func() { - os.Unsetenv("ADDR") - os.Unsetenv("METRICS_ADDR") - os.Unsetenv("METRICS_PATH") - }() + // Set environment variables + t.Setenv("ADDR", ":8888") + t.Setenv("METRICS_ADDR", ":9999") + t.Setenv("METRICS_PATH", "/custom-metrics") config, err := LoadFromEnv() if err != nil { @@ -101,16 +104,18 @@ func TestLoadFromEnv(t *testing.T) { } func TestHandleFunc(t *testing.T) { + t.Parallel() + svc := New("test", nil) // Add a simple handler - svc.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) { + svc.HandleFunc("/test", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("test response")) }) // Create a test request - req := httptest.NewRequest("GET", "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", nil) recorder := httptest.NewRecorder() // Serve the request @@ -126,6 +131,8 @@ func TestHandleFunc(t *testing.T) { } func TestGetLogger(t *testing.T) { + t.Parallel() + svc := New("test", nil) // Test with a request that has logger middleware applied @@ -138,7 +145,7 @@ func TestGetLogger(t *testing.T) { w.WriteHeader(http.StatusOK) }), LoggerMiddleware(svc.Logger)) - req := httptest.NewRequest("GET", "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", nil) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) @@ -149,6 +156,8 @@ func TestGetLogger(t *testing.T) { } func TestGetMetrics(t *testing.T) { + t.Parallel() + svc := New("test", nil) // Test with a request that has metrics middleware applied @@ -161,7 +170,7 @@ func TestGetMetrics(t *testing.T) { w.WriteHeader(http.StatusOK) }), MetricsMiddleware(svc.Metrics)) - req := httptest.NewRequest("GET", "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", nil) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) @@ -172,14 +181,16 @@ func TestGetMetrics(t *testing.T) { } func TestRecoveryMiddleware(t *testing.T) { + t.Parallel() + svc := New("test", nil) // Create a handler that panics - handler := applyMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := applyMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { panic("test panic") }), RecoveryMiddleware(svc.Logger)) - req := httptest.NewRequest("GET", "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", nil) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) @@ -194,6 +205,8 @@ func TestRecoveryMiddleware(t *testing.T) { } func TestMetricsMiddleware(t *testing.T) { + t.Parallel() + svc := New("test", nil) // Create a simple handler @@ -202,7 +215,7 @@ func TestMetricsMiddleware(t *testing.T) { w.Write([]byte("test")) }), MetricsMiddleware(svc.Metrics)) - req := httptest.NewRequest("GET", "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", nil) recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) @@ -210,11 +223,11 @@ func TestMetricsMiddleware(t *testing.T) { if recorder.Code != http.StatusOK { t.Errorf("expected status 200, got %d", recorder.Code) } - // Note: In a real test, you'd check if the metrics were actually recorded - // This would require exposing the metrics or using a metrics registry } func TestShutdownHooks(t *testing.T) { + t.Parallel() + svc := New("test", nil) hookCalled := false @@ -225,8 +238,8 @@ func TestShutdownHooks(t *testing.T) { }) // Create a minimal server setup - svc.server = &http.Server{Addr: ":0"} - svc.metricsServer = &http.Server{Addr: ":0"} + svc.server = &http.Server{Addr: ":0"} //nolint:gosec + svc.metricsServer = &http.Server{Addr: ":0"} //nolint:gosec // Test graceful shutdown err := svc.gracefulShutdown() @@ -240,6 +253,8 @@ func TestShutdownHooks(t *testing.T) { } func TestUse(t *testing.T) { + t.Parallel() + svc := New("test", nil) initialCount := len(svc.middlewares) @@ -257,11 +272,11 @@ func TestUse(t *testing.T) { } // Test that the custom middleware is applied - svc.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) { + svc.HandleFunc("/test", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) - req := httptest.NewRequest("GET", "/test", nil) + req := httptest.NewRequest(http.MethodGet, "/test", nil) recorder := httptest.NewRecorder() svc.mux.ServeHTTP(recorder, req) @@ -272,6 +287,8 @@ func TestUse(t *testing.T) { } func TestIntegration(t *testing.T) { + t.Parallel() + // Create a service with custom configuration config := DefaultConfig() config.Addr = ":0" // Use random port @@ -289,7 +306,7 @@ func TestIntegration(t *testing.T) { }) // Test the handler - req := httptest.NewRequest("GET", "/hello", nil) + req := httptest.NewRequest(http.MethodGet, "/hello", nil) recorder := httptest.NewRecorder() svc.mux.ServeHTTP(recorder, req) @@ -306,6 +323,8 @@ func TestIntegration(t *testing.T) { } func TestStartMetricsServer(t *testing.T) { + t.Parallel() + svc := New("test", nil) // Test that metrics server can be started (we'll use a mock) @@ -332,11 +351,11 @@ func BenchmarkHandleFunc(b *testing.B) { svc.HandleFunc("/benchmark", handler) - req := httptest.NewRequest("GET", "/benchmark", nil) + req := httptest.NewRequest(http.MethodGet, "/benchmark", nil) b.ResetTimer() - for i := 0; i < b.N; i++ { + for range b.N { recorder := httptest.NewRecorder() svc.mux.ServeHTTP(recorder, req) } @@ -352,11 +371,11 @@ func BenchmarkMiddleware(b *testing.B) { wrappedHandler := applyMiddleware(handler, svc.middlewares...) - req := httptest.NewRequest("GET", "/benchmark", nil) + req := httptest.NewRequest(http.MethodGet, "/benchmark", nil) b.ResetTimer() - for i := 0; i < b.N; i++ { + for range b.N { recorder := httptest.NewRecorder() wrappedHandler.ServeHTTP(recorder, req) } diff --git a/shutdown.go b/shutdown.go index 0ddd2d6..7d53f84 100644 --- a/shutdown.go +++ b/shutdown.go @@ -18,7 +18,6 @@ func (s *Service) gracefulShutdown() error { if err := hook(); err != nil { s.Logger.Error("shutdown hook failed", "index", i, "error", err) - // Continue with other hooks even if one fails } } From 41d39c89d58f3fb7b43ba50121ec13feb94f8644 Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Sat, 12 Jul 2025 01:59:32 +0200 Subject: [PATCH 13/17] chore: rewrote metrics system --- README.md | 157 +++++- _examples/custom-metrics/main.go | 382 +++++++++++++ _examples/prometheus-counter/main.go | 150 +++--- metrics.go | 425 +++++++++++++-- metrics_test.go | 774 +++++++++++++++++++++++++++ service.go | 20 + service_test.go | 2 - 7 files changed, 1773 insertions(+), 137 deletions(-) create mode 100644 _examples/custom-metrics/main.go create mode 100644 metrics_test.go diff --git a/README.md b/README.md index f22bb97..4396fd2 100644 --- a/README.md +++ b/README.md @@ -180,24 +180,124 @@ svc.Use(func(next http.Handler) http.Handler { ## Metrics -The framework automatically collects Prometheus metrics: +The framework provides a flexible metrics system with built-in HTTP metrics and support for custom metrics. + +### Built-in HTTP Metrics + +The framework automatically collects these Prometheus metrics: - `{service_name}_http_requests_total`: Total HTTP requests - `{service_name}_http_request_duration_seconds`: Request duration - `{service_name}_http_requests_in_flight`: In-flight requests -Metrics are available at `:9090/metrics` by default. +### Custom Metrics + +You can easily register and use custom metrics in your service: + +```go +func main() { + svc := service.New("my-service", nil) + + // Register custom metrics + svc.RegisterCounter(service.MetricConfig{ + Name: "user_registrations_total", + Help: "Total number of user registrations", + Labels: []string{"source", "status"}, + }) + + svc.RegisterGauge(service.MetricConfig{ + Name: "active_users", + Help: "Number of currently active users", + Labels: []string{"user_type"}, + }) + + svc.RegisterHistogram(service.MetricConfig{ + Name: "request_processing_duration_seconds", + Help: "Time spent processing requests", + Labels: []string{"operation"}, + Buckets: []float64{0.001, 0.01, 0.1, 1.0, 10.0}, + }) + + svc.RegisterSummary(service.MetricConfig{ + Name: "response_size_bytes", + Help: "Size of responses in bytes", + Labels: []string{"endpoint"}, + Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, + }) + + // Use metrics in handlers + svc.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { + // Increment counter + service.IncCounter(r, "user_registrations_total", "web", "success") + + // Set gauge value + service.SetGauge(r, "active_users", 42.0, "premium") + + // Observe histogram + service.ObserveHistogram(r, "request_processing_duration_seconds", 0.25, "registration") + + // Observe summary + service.ObserveSummary(r, "response_size_bytes", 1024.0, "/register") + + w.Write([]byte("User registered")) + }) + + svc.Start() +} +``` + +### Metric Types + +The framework supports all standard Prometheus metric types: + +- **Counter**: Monotonically increasing values (e.g., total requests, errors) +- **Gauge**: Values that can go up and down (e.g., active connections, memory usage) +- **Histogram**: Observations in configurable buckets (e.g., request duration, response size) +- **Summary**: Observations with configurable quantiles (e.g., request latency percentiles) + +### Helper Functions + +Use these helper functions in your HTTP handlers to manipulate metrics: + +```go +// Counter operations +service.IncCounter(r, "metric_name", "label1", "label2") // Increment by 1 +service.AddCounter(r, "metric_name", 5.0, "label1", "label2") // Add specific value + +// Gauge operations +service.SetGauge(r, "metric_name", 42.0, "label1") // Set to specific value +service.IncGauge(r, "metric_name", "label1") // Increment by 1 +service.DecGauge(r, "metric_name", "label1") // Decrement by 1 +service.AddGauge(r, "metric_name", 5.0, "label1") // Add specific value + +// Histogram operations +service.ObserveHistogram(r, "metric_name", 0.25, "label1") // Observe a value + +// Summary operations +service.ObserveSummary(r, "metric_name", 1024.0, "label1") // Observe a value +``` + +### Direct Access + +For advanced use cases, you can access the metrics collector directly: ```go -// Access metrics in handlers func myHandler(w http.ResponseWriter, r *http.Request) { metrics := service.GetMetrics(r) if metrics != nil { - // Custom metric operations can be added here + // Direct access to metrics collector + metrics.IncCounter("my_counter", "label_value") + metrics.SetGauge("my_gauge", 42.0, "label_value") + + // Access the underlying Prometheus registry + registry := metrics.GetRegistry() + // Use registry for custom integrations } } ``` +All metrics are available at `:9090/metrics` by default. + ## Graceful Shutdown The framework includes graceful shutdown by default with signal handling and custom hooks: @@ -345,29 +445,56 @@ The library provides all the boilerplate needed for Kubernetes deployments: ## Examples -See the `_example/` directory for complete working examples demonstrating: +See the `_examples/` directory for complete working examples demonstrating: -- Basic service setup -- Custom middleware -- Environment configuration -- Graceful shutdown -- Metrics integration +- **minimal/**: Basic service setup with default configuration +- **custom-metrics/**: Comprehensive custom metrics registration and usage +- **prometheus-counter/**: Advanced metrics patterns and business logic tracking +- **health-check-***: Various health check integrations +- **shutdown-hook/**: Graceful shutdown with custom cleanup -### Running the Example +### Running the Examples ```bash -cd _example +# Basic minimal service +cd _examples/minimal +go run main.go + +# Custom metrics demonstration +cd _examples/custom-metrics +go run main.go + +# Advanced Prometheus metrics +cd _examples/prometheus-counter go run main.go ``` -Test the endpoints: +### Testing Custom Metrics + +After running the custom metrics example: + ```bash -curl http://localhost:8080/ -curl http://localhost:8080/metrics-demo +# Generate user registrations +curl "http://localhost:8080/register?source=web" +curl "http://localhost:8080/register?source=mobile" + +# Generate orders +curl "http://localhost:8080/order?category=electronics&payment=credit_card" +curl "http://localhost:8080/order?category=books&payment=paypal" + +# Admin metric operations +curl -X POST "http://localhost:8080/admin/metrics?action=add_users" +curl -X POST "http://localhost:8080/admin/metrics?action=clear_queue&queue=email" + +# Check all endpoints +curl http://localhost:8080/status curl http://localhost:9090/health # Comprehensive health check curl http://localhost:9090/ready # Readiness probe curl http://localhost:9090/live # Liveness probe curl http://localhost:9090/metrics # Prometheus metrics + +# Filter custom metrics +curl http://localhost:9090/metrics | grep -E "(user_registrations|orders_total|active_users)" ``` ## Best Practices diff --git a/_examples/custom-metrics/main.go b/_examples/custom-metrics/main.go new file mode 100644 index 0000000..40d197c --- /dev/null +++ b/_examples/custom-metrics/main.go @@ -0,0 +1,382 @@ +package main + +import ( + "fmt" + "math/rand" + "net/http" + "os" + "time" + + "atomicgo.dev/service" +) + +func main() { + svc := service.New("custom-metrics-service", nil) + + // Register custom business metrics + err := svc.RegisterCounter(service.MetricConfig{ + Name: "user_registrations_total", + Help: "Total number of user registrations", + Labels: []string{"source", "status"}, + }) + if err != nil { + svc.Logger.Error("Failed to register user registrations counter", "error", err) + os.Exit(1) + } + + err = svc.RegisterCounter(service.MetricConfig{ + Name: "orders_total", + Help: "Total number of orders placed", + Labels: []string{"product_category", "payment_method"}, + }) + if err != nil { + svc.Logger.Error("Failed to register orders counter", "error", err) + os.Exit(1) + } + + err = svc.RegisterGauge(service.MetricConfig{ + Name: "active_users", + Help: "Number of currently active users", + Labels: []string{"user_type"}, + }) + if err != nil { + svc.Logger.Error("Failed to register active users gauge", "error", err) + os.Exit(1) + } + + err = svc.RegisterGauge(service.MetricConfig{ + Name: "queue_size", + Help: "Current size of processing queues", + Labels: []string{"queue_name"}, + }) + if err != nil { + svc.Logger.Error("Failed to register queue size gauge", "error", err) + os.Exit(1) + } + + err = svc.RegisterHistogram(service.MetricConfig{ + Name: "request_processing_duration_seconds", + Help: "Time spent processing business requests", + Labels: []string{"operation", "result"}, + Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 2.0, 5.0}, + }) + if err != nil { + svc.Logger.Error("Failed to register request processing duration histogram", "error", err) + os.Exit(1) + } + + err = svc.RegisterSummary(service.MetricConfig{ + Name: "response_size_bytes", + Help: "Size of API responses in bytes", + Labels: []string{"endpoint", "content_type"}, + Objectives: map[float64]float64{ + 0.5: 0.05, + 0.9: 0.01, + 0.95: 0.005, + 0.99: 0.001, + }, + }) + if err != nil { + svc.Logger.Error("Failed to register response size summary", "error", err) + os.Exit(1) + } + + // Simulate background metric updates + go func() { + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + // Simulate changing active users + premiumUsers := rand.Intn(50) + 20 + freeUsers := rand.Intn(200) + 100 + + svc.Metrics.SetGauge("active_users", float64(premiumUsers), "premium") + svc.Metrics.SetGauge("active_users", float64(freeUsers), "free") + + // Simulate queue sizes + svc.Metrics.SetGauge("queue_size", float64(rand.Intn(20)), "email") + svc.Metrics.SetGauge("queue_size", float64(rand.Intn(50)), "notifications") + svc.Metrics.SetGauge("queue_size", float64(rand.Intn(30)), "analytics") + + svc.Logger.Info("Updated background metrics", + "premium_users", premiumUsers, + "free_users", freeUsers) + } + } + }() + + // User registration endpoint + svc.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + start := time.Now() + + // Simulate registration logic + source := r.URL.Query().Get("source") + if source == "" { + source = "web" + } + + // Simulate random success/failure + success := rand.Float32() > 0.1 // 90% success rate + status := "success" + if !success { + status = "failure" + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Registration failed")) + } else { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Registration successful")) + } + + // Record metrics + err := service.IncCounter(r, "user_registrations_total", source, status) + if err != nil { + logger.Error("Failed to increment user registrations counter", "error", err) + } + + // Record processing time + duration := time.Since(start).Seconds() + err = service.ObserveHistogram(r, "request_processing_duration_seconds", duration, "registration", status) + if err != nil { + logger.Error("Failed to observe processing duration", "error", err) + } + + // Record response size + responseSize := float64(len("Registration successful")) + err = service.ObserveSummary(r, "response_size_bytes", responseSize, "/register", "text/plain") + if err != nil { + logger.Error("Failed to observe response size", "error", err) + } + + logger.Info("User registration processed", + "source", source, + "status", status, + "duration", duration) + }) + + // Order placement endpoint + svc.HandleFunc("/order", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + start := time.Now() + + // Get parameters + category := r.URL.Query().Get("category") + if category == "" { + category = "electronics" + } + + paymentMethod := r.URL.Query().Get("payment") + if paymentMethod == "" { + paymentMethod = "credit_card" + } + + // Simulate order processing + time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond) + + // Simulate random success/failure + success := rand.Float32() > 0.05 // 95% success rate + status := "success" + if !success { + status = "failure" + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Order failed")) + } else { + w.WriteHeader(http.StatusOK) + w.Write([]byte(fmt.Sprintf("Order placed for %s via %s", category, paymentMethod))) + } + + // Record metrics + err := service.IncCounter(r, "orders_total", category, paymentMethod) + if err != nil { + logger.Error("Failed to increment orders counter", "error", err) + } + + // Record processing time + duration := time.Since(start).Seconds() + err = service.ObserveHistogram(r, "request_processing_duration_seconds", duration, "order", status) + if err != nil { + logger.Error("Failed to observe processing duration", "error", err) + } + + // Record response size + responseContent := fmt.Sprintf("Order placed for %s via %s", category, paymentMethod) + responseSize := float64(len(responseContent)) + err = service.ObserveSummary(r, "response_size_bytes", responseSize, "/order", "text/plain") + if err != nil { + logger.Error("Failed to observe response size", "error", err) + } + + logger.Info("Order processed", + "category", category, + "payment_method", paymentMethod, + "status", status, + "duration", duration) + }) + + // Admin endpoint to manually update metrics + svc.HandleFunc("/admin/metrics", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + + switch r.Method { + case http.MethodPost: + // Simulate admin actions that affect metrics + action := r.URL.Query().Get("action") + + switch action { + case "add_users": + // Simulate adding users + count := rand.Intn(10) + 1 + err := service.AddGauge(r, "active_users", float64(count), "premium") + if err != nil { + logger.Error("Failed to add to active users gauge", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Write([]byte(fmt.Sprintf("Added %d premium users", count))) + + case "clear_queue": + // Simulate clearing a queue + queueName := r.URL.Query().Get("queue") + if queueName == "" { + queueName = "email" + } + err := service.SetGauge(r, "queue_size", 0, queueName) + if err != nil { + logger.Error("Failed to clear queue gauge", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Write([]byte(fmt.Sprintf("Cleared %s queue", queueName))) + + default: + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Unknown action")) + } + + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write([]byte("Method not allowed")) + } + }) + + // Status endpoint showing current metrics + svc.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + logger.Info("Status endpoint requested") + + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("Custom Metrics Demo Service Status\n")) + w.Write([]byte("==================================\n\n")) + w.Write([]byte("Available endpoints:\n")) + w.Write([]byte("- GET /register?source=web|mobile|api\n")) + w.Write([]byte("- GET /order?category=electronics|books|clothing&payment=credit_card|paypal|crypto\n")) + w.Write([]byte("- POST /admin/metrics?action=add_users|clear_queue&queue=email|notifications|analytics\n")) + w.Write([]byte("- GET /status (this endpoint)\n\n")) + w.Write([]byte("Metrics available at: http://localhost:9090/metrics\n\n")) + w.Write([]byte("Custom metrics registered:\n")) + w.Write([]byte("- user_registrations_total: Counter tracking user registrations by source and status\n")) + w.Write([]byte("- orders_total: Counter tracking orders by category and payment method\n")) + w.Write([]byte("- active_users: Gauge showing current active users by type\n")) + w.Write([]byte("- queue_size: Gauge showing current queue sizes\n")) + w.Write([]byte("- request_processing_duration_seconds: Histogram of request processing times\n")) + w.Write([]byte("- response_size_bytes: Summary of response sizes\n")) + w.Write([]byte("\nBuilt-in HTTP metrics are also available (requests_total, request_duration, etc.)\n")) + }) + + // Root endpoint + svc.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + logger := service.GetLogger(r) + logger.Info("Root endpoint accessed") + + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(` + + + + Custom Metrics Demo + + + +

Custom Metrics Demo Service

+

This service demonstrates the new flexible metrics system with custom metrics registration and manipulation.

+ +
+ GET /register?source=web|mobile|api +
Simulates user registration and tracks metrics by source and status.
+
+ +
+ GET /order?category=electronics|books|clothing&payment=credit_card|paypal|crypto +
Simulates order placement and tracks metrics by category and payment method.
+
+ +
+ POST /admin/metrics?action=add_users|clear_queue&queue=email|notifications|analytics +
Admin endpoint to manually manipulate metrics.
+
+ +
+ GET /status +
Shows service status and available endpoints.
+
+ + View Prometheus Metrics + +

Try these commands:

+
+# Generate some user registrations
+curl "http://localhost:8080/register?source=web"
+curl "http://localhost:8080/register?source=mobile"
+curl "http://localhost:8080/register?source=api"
+
+# Generate some orders
+curl "http://localhost:8080/order?category=electronics&payment=credit_card"
+curl "http://localhost:8080/order?category=books&payment=paypal"
+curl "http://localhost:8080/order?category=clothing&payment=crypto"
+
+# Admin actions
+curl -X POST "http://localhost:8080/admin/metrics?action=add_users"
+curl -X POST "http://localhost:8080/admin/metrics?action=clear_queue&queue=email"
+
+# Check metrics
+curl http://localhost:9090/metrics | grep -E "(user_registrations|orders_total|active_users|queue_size)"
+    
+ + + `)) + }) + + svc.Logger.Info("Starting Custom Metrics Demo Service...") + svc.Logger.Info("Service available at http://localhost:8080") + svc.Logger.Info("Prometheus metrics at http://localhost:9090/metrics") + svc.Logger.Info("") + svc.Logger.Info("Custom metrics registered:") + svc.Logger.Info("- user_registrations_total: Counter for user registrations") + svc.Logger.Info("- orders_total: Counter for orders placed") + svc.Logger.Info("- active_users: Gauge for active users") + svc.Logger.Info("- queue_size: Gauge for queue sizes") + svc.Logger.Info("- request_processing_duration_seconds: Histogram for processing times") + svc.Logger.Info("- response_size_bytes: Summary for response sizes") + svc.Logger.Info("") + svc.Logger.Info("Try the endpoints:") + svc.Logger.Info(" curl 'http://localhost:8080/register?source=web'") + svc.Logger.Info(" curl 'http://localhost:8080/order?category=electronics&payment=credit_card'") + svc.Logger.Info(" curl -X POST 'http://localhost:8080/admin/metrics?action=add_users'") + svc.Logger.Info(" curl http://localhost:8080/status") + + if err := svc.Start(); err != nil { + svc.Logger.Error("Failed to start service", "error", err) + os.Exit(1) + } +} diff --git a/_examples/prometheus-counter/main.go b/_examples/prometheus-counter/main.go index f9ca79e..f008df5 100644 --- a/_examples/prometheus-counter/main.go +++ b/_examples/prometheus-counter/main.go @@ -9,68 +9,74 @@ import ( "time" "atomicgo.dev/service" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" -) - -var ( - // Custom counters - requestCounter = promauto.NewCounterVec( - prometheus.CounterOpts{ - Name: "myapp_requests_total", - Help: "Total number of requests processed", - }, - []string{"method", "endpoint", "status"}, - ) - - businessMetricCounter = promauto.NewCounterVec( - prometheus.CounterOpts{ - Name: "myapp_business_events_total", - Help: "Total number of business events processed", - }, - []string{"event_type", "result"}, - ) - - // Custom gauges - activeUsersGauge = promauto.NewGauge( - prometheus.GaugeOpts{ - Name: "myapp_active_users", - Help: "Number of currently active users", - }, - ) - - queueSizeGauge = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "myapp_queue_size", - Help: "Current size of processing queues", - }, - []string{"queue_name"}, - ) - - // Custom histograms - processingDuration = promauto.NewHistogramVec( - prometheus.HistogramOpts{ - Name: "myapp_processing_duration_seconds", - Help: "Time spent processing requests", - Buckets: prometheus.DefBuckets, - }, - []string{"operation"}, - ) - - // Custom summary - requestSize = promauto.NewSummaryVec( - prometheus.SummaryOpts{ - Name: "myapp_request_size_bytes", - Help: "Size of requests in bytes", - Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, - }, - []string{"endpoint"}, - ) ) func main() { svc := service.New("prometheus-counter-service", nil) + // Register custom metrics using the new flexible system + err := svc.RegisterCounter(service.MetricConfig{ + Name: "myapp_requests_total", + Help: "Total number of requests processed", + Labels: []string{"method", "endpoint", "status"}, + }) + if err != nil { + svc.Logger.Error("Failed to register requests counter", "error", err) + os.Exit(1) + } + + err = svc.RegisterCounter(service.MetricConfig{ + Name: "myapp_business_events_total", + Help: "Total number of business events processed", + Labels: []string{"event_type", "result"}, + }) + if err != nil { + svc.Logger.Error("Failed to register business events counter", "error", err) + os.Exit(1) + } + + err = svc.RegisterGauge(service.MetricConfig{ + Name: "myapp_active_users", + Help: "Number of currently active users", + Labels: []string{}, // No labels for this gauge + }) + if err != nil { + svc.Logger.Error("Failed to register active users gauge", "error", err) + os.Exit(1) + } + + err = svc.RegisterGauge(service.MetricConfig{ + Name: "myapp_queue_size", + Help: "Current size of processing queues", + Labels: []string{"queue_name"}, + }) + if err != nil { + svc.Logger.Error("Failed to register queue size gauge", "error", err) + os.Exit(1) + } + + err = svc.RegisterHistogram(service.MetricConfig{ + Name: "myapp_processing_duration_seconds", + Help: "Time spent processing requests", + Labels: []string{"operation"}, + // Using default buckets + }) + if err != nil { + svc.Logger.Error("Failed to register processing duration histogram", "error", err) + os.Exit(1) + } + + err = svc.RegisterSummary(service.MetricConfig{ + Name: "myapp_request_size_bytes", + Help: "Size of requests in bytes", + Labels: []string{"endpoint"}, + Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, + }) + if err != nil { + svc.Logger.Error("Failed to register request size summary", "error", err) + os.Exit(1) + } + // Simulate background metrics collection go func() { ticker := time.NewTicker(2 * time.Second) @@ -80,12 +86,12 @@ func main() { select { case <-ticker.C: // Simulate changing active users - activeUsersGauge.Set(float64(rand.Intn(100) + 50)) + svc.Metrics.SetGauge("myapp_active_users", float64(rand.Intn(100)+50)) // Simulate queue sizes - queueSizeGauge.WithLabelValues("email").Set(float64(rand.Intn(20))) - queueSizeGauge.WithLabelValues("notifications").Set(float64(rand.Intn(50))) - queueSizeGauge.WithLabelValues("analytics").Set(float64(rand.Intn(30))) + svc.Metrics.SetGauge("myapp_queue_size", float64(rand.Intn(20)), "email") + svc.Metrics.SetGauge("myapp_queue_size", float64(rand.Intn(50)), "notifications") + svc.Metrics.SetGauge("myapp_queue_size", float64(rand.Intn(30)), "analytics") // Simulate some business events eventTypes := []string{"user_signup", "purchase", "login", "logout"} @@ -94,7 +100,7 @@ func main() { for i := 0; i < rand.Intn(5); i++ { eventType := eventTypes[rand.Intn(len(eventTypes))] result := results[rand.Intn(len(results))] - businessMetricCounter.WithLabelValues(eventType, result).Inc() + svc.Metrics.IncCounter("myapp_business_events_total", eventType, result) } } } @@ -107,7 +113,7 @@ func main() { // Track request size if r.ContentLength > 0 { - requestSize.WithLabelValues(r.URL.Path).Observe(float64(r.ContentLength)) + service.ObserveSummary(r, "myapp_request_size_bytes", float64(r.ContentLength), r.URL.Path) } // Wrap response writer to capture status code @@ -118,8 +124,8 @@ func main() { // Record metrics duration := time.Since(start) - requestCounter.WithLabelValues(r.Method, r.URL.Path, strconv.Itoa(wrapper.statusCode)).Inc() - processingDuration.WithLabelValues("http_request").Observe(duration.Seconds()) + service.IncCounter(r, "myapp_requests_total", r.Method, r.URL.Path, strconv.Itoa(wrapper.statusCode)) + service.ObserveHistogram(r, "myapp_processing_duration_seconds", duration.Seconds(), "http_request") }) }) @@ -156,11 +162,11 @@ func main() { // Simulate success/failure if rand.Float32() < 0.9 { - businessMetricCounter.WithLabelValues("user_creation", "success").Inc() + service.IncCounter(r, "myapp_business_events_total", "user_creation", "success") w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"status": "created"}`)) } else { - businessMetricCounter.WithLabelValues("user_creation", "failure").Inc() + service.IncCounter(r, "myapp_business_events_total", "user_creation", "failure") w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error": "validation failed"}`)) } @@ -188,11 +194,11 @@ func main() { // Simulate success/failure if rand.Float32() < 0.8 { - businessMetricCounter.WithLabelValues("order_creation", "success").Inc() + service.IncCounter(r, "myapp_business_events_total", "order_creation", "success") w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"status": "created", "order_id": 123}`)) } else { - businessMetricCounter.WithLabelValues("order_creation", "failure").Inc() + service.IncCounter(r, "myapp_business_events_total", "order_creation", "failure") w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error": "insufficient funds"}`)) } @@ -218,15 +224,15 @@ func main() { duration := time.Since(start) // Record processing duration - processingDuration.WithLabelValues("heavy_processing").Observe(duration.Seconds()) + service.ObserveHistogram(r, "myapp_processing_duration_seconds", duration.Seconds(), "heavy_processing") // Simulate success/failure if rand.Float32() < 0.7 { - businessMetricCounter.WithLabelValues("heavy_processing", "success").Inc() + service.IncCounter(r, "myapp_business_events_total", "heavy_processing", "success") w.WriteHeader(http.StatusOK) w.Write([]byte(fmt.Sprintf(`{"status": "completed", "duration": "%v"}`, duration))) } else { - businessMetricCounter.WithLabelValues("heavy_processing", "failure").Inc() + service.IncCounter(r, "myapp_business_events_total", "heavy_processing", "failure") w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error": "processing failed"}`)) } diff --git a/metrics.go b/metrics.go index e44ecd7..76b22c9 100644 --- a/metrics.go +++ b/metrics.go @@ -2,55 +2,331 @@ package service import ( "context" + "errors" + "fmt" "net/http" "strconv" + "sync" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) -// MetricsCollector holds all the metrics for the service +// MetricsCollector holds all the metrics for the service with a flexible registry type MetricsCollector struct { + serviceName string + registry *prometheus.Registry + mu sync.RWMutex + + // Built-in HTTP metrics (always available) httpRequestsTotal *prometheus.CounterVec httpRequestDuration *prometheus.HistogramVec httpRequestsInFlight prometheus.Gauge + + // Custom metrics registry + counters map[string]*prometheus.CounterVec + gauges map[string]*prometheus.GaugeVec + histograms map[string]*prometheus.HistogramVec + summaries map[string]*prometheus.SummaryVec } -// NewMetricsCollector creates a new metrics collector +// MetricConfig holds configuration for creating custom metrics +type MetricConfig struct { + Name string + Help string + Labels []string + Buckets []float64 // For histograms + Objectives map[float64]float64 // For summaries +} + +// NewMetricsCollector creates a new metrics collector with a flexible registry func NewMetricsCollector(serviceName string) *MetricsCollector { + registry := prometheus.NewRegistry() + metricsCollector := &MetricsCollector{ - httpRequestsTotal: prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: serviceName + "_http_requests_total", - Help: "Total number of HTTP requests", - }, - []string{"method", "endpoint", "status_code"}, - ), - httpRequestDuration: prometheus.NewHistogramVec( - prometheus.HistogramOpts{ - Name: serviceName + "_http_request_duration_seconds", - Help: "HTTP request duration in seconds", - Buckets: prometheus.DefBuckets, - }, - []string{"method", "endpoint", "status_code"}, - ), - httpRequestsInFlight: prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: serviceName + "_http_requests_in_flight", - Help: "Number of HTTP requests currently being processed", - }, - ), - } - - // Register metrics with Prometheus (ignore if already registered) - _ = prometheus.DefaultRegisterer.Register(metricsCollector.httpRequestsTotal) - _ = prometheus.DefaultRegisterer.Register(metricsCollector.httpRequestDuration) - _ = prometheus.DefaultRegisterer.Register(metricsCollector.httpRequestsInFlight) + serviceName: serviceName, + registry: registry, + counters: make(map[string]*prometheus.CounterVec), + gauges: make(map[string]*prometheus.GaugeVec), + histograms: make(map[string]*prometheus.HistogramVec), + summaries: make(map[string]*prometheus.SummaryVec), + } + + // Create built-in HTTP metrics + metricsCollector.httpRequestsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: serviceName + "_http_requests_total", + Help: "Total number of HTTP requests", + }, + []string{"method", "endpoint", "status_code"}, + ) + + metricsCollector.httpRequestDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: serviceName + "_http_request_duration_seconds", + Help: "HTTP request duration in seconds", + Buckets: prometheus.DefBuckets, + }, + []string{"method", "endpoint", "status_code"}, + ) + + metricsCollector.httpRequestsInFlight = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: serviceName + "_http_requests_in_flight", + Help: "Number of HTTP requests currently being processed", + }, + ) + + // Register built-in metrics + registry.MustRegister(metricsCollector.httpRequestsTotal) + registry.MustRegister(metricsCollector.httpRequestDuration) + registry.MustRegister(metricsCollector.httpRequestsInFlight) return metricsCollector } +// RegisterCounter registers a new counter metric +func (mc *MetricsCollector) RegisterCounter(config MetricConfig) error { + mc.mu.Lock() + defer mc.mu.Unlock() + + if _, exists := mc.counters[config.Name]; exists { + return fmt.Errorf("counter %s already exists", config.Name) //nolint:err113 + } + + counter := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: config.Name, + Help: config.Help, + }, + config.Labels, + ) + + if err := mc.registry.Register(counter); err != nil { + return fmt.Errorf("failed to register counter %s: %w", config.Name, err) + } + + mc.counters[config.Name] = counter + + return nil +} + +// RegisterGauge registers a new gauge metric +func (mc *MetricsCollector) RegisterGauge(config MetricConfig) error { + mc.mu.Lock() + defer mc.mu.Unlock() + + if _, exists := mc.gauges[config.Name]; exists { + return fmt.Errorf("gauge %s already exists", config.Name) //nolint:err113 + } + + gauge := prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: config.Name, + Help: config.Help, + }, + config.Labels, + ) + + if err := mc.registry.Register(gauge); err != nil { + return fmt.Errorf("failed to register gauge %s: %w", config.Name, err) + } + + mc.gauges[config.Name] = gauge + + return nil +} + +// RegisterHistogram registers a new histogram metric +func (mc *MetricsCollector) RegisterHistogram(config MetricConfig) error { + mc.mu.Lock() + defer mc.mu.Unlock() + + if _, exists := mc.histograms[config.Name]; exists { + return fmt.Errorf("histogram %s already exists", config.Name) //nolint:err113 + } + + buckets := config.Buckets + if len(buckets) == 0 { + buckets = prometheus.DefBuckets + } + + histogram := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: config.Name, + Help: config.Help, + Buckets: buckets, + }, + config.Labels, + ) + + if err := mc.registry.Register(histogram); err != nil { + return fmt.Errorf("failed to register histogram %s: %w", config.Name, err) + } + + mc.histograms[config.Name] = histogram + + return nil +} + +// RegisterSummary registers a new summary metric +func (mc *MetricsCollector) RegisterSummary(config MetricConfig) error { + mc.mu.Lock() + defer mc.mu.Unlock() + + if _, exists := mc.summaries[config.Name]; exists { + return fmt.Errorf("summary %s already exists", config.Name) //nolint:err113 + } + + objectives := config.Objectives + if len(objectives) == 0 { + objectives = map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001} + } + + summary := prometheus.NewSummaryVec( + prometheus.SummaryOpts{ + Name: config.Name, + Help: config.Help, + Objectives: objectives, + }, + config.Labels, + ) + + if err := mc.registry.Register(summary); err != nil { + return fmt.Errorf("failed to register summary %s: %w", config.Name, err) + } + + mc.summaries[config.Name] = summary + + return nil +} + +// IncCounter increments a counter metric +func (mc *MetricsCollector) IncCounter(name string, labels ...string) error { + mc.mu.RLock() + defer mc.mu.RUnlock() + + counter, exists := mc.counters[name] + if !exists { + return fmt.Errorf("counter %s not found", name) //nolint:err113 + } + + counter.WithLabelValues(labels...).Inc() + + return nil +} + +// AddCounter adds a value to a counter metric +func (mc *MetricsCollector) AddCounter(name string, value float64, labels ...string) error { + mc.mu.RLock() + defer mc.mu.RUnlock() + + counter, exists := mc.counters[name] + if !exists { + return fmt.Errorf("counter %s not found", name) //nolint:err113 + } + + counter.WithLabelValues(labels...).Add(value) + + return nil +} + +// SetGauge sets a gauge metric value +func (mc *MetricsCollector) SetGauge(name string, value float64, labels ...string) error { + mc.mu.RLock() + defer mc.mu.RUnlock() + + gauge, exists := mc.gauges[name] + if !exists { + return fmt.Errorf("gauge %s not found", name) //nolint:err113 + } + + gauge.WithLabelValues(labels...).Set(value) + + return nil +} + +// IncGauge increments a gauge metric +func (mc *MetricsCollector) IncGauge(name string, labels ...string) error { + mc.mu.RLock() + defer mc.mu.RUnlock() + + gauge, exists := mc.gauges[name] + if !exists { + return fmt.Errorf("gauge %s not found", name) //nolint:err113 + } + + gauge.WithLabelValues(labels...).Inc() + + return nil +} + +// DecGauge decrements a gauge metric +func (mc *MetricsCollector) DecGauge(name string, labels ...string) error { + mc.mu.RLock() + defer mc.mu.RUnlock() + + gauge, exists := mc.gauges[name] + if !exists { + return fmt.Errorf("gauge %s not found", name) //nolint:err113 + } + + gauge.WithLabelValues(labels...).Dec() + + return nil +} + +// AddGauge adds a value to a gauge metric +func (mc *MetricsCollector) AddGauge(name string, value float64, labels ...string) error { + mc.mu.RLock() + defer mc.mu.RUnlock() + + gauge, exists := mc.gauges[name] + if !exists { + return fmt.Errorf("gauge %s not found", name) //nolint:err113 + } + + gauge.WithLabelValues(labels...).Add(value) + + return nil +} + +// ObserveHistogram observes a value in a histogram metric +func (mc *MetricsCollector) ObserveHistogram(name string, value float64, labels ...string) error { + mc.mu.RLock() + defer mc.mu.RUnlock() + + histogram, exists := mc.histograms[name] + if !exists { + return fmt.Errorf("histogram %s not found", name) //nolint:err113 + } + + histogram.WithLabelValues(labels...).Observe(value) + + return nil +} + +// ObserveSummary observes a value in a summary metric +func (mc *MetricsCollector) ObserveSummary(name string, value float64, labels ...string) error { + mc.mu.RLock() + defer mc.mu.RUnlock() + + summary, exists := mc.summaries[name] + if !exists { + return fmt.Errorf("summary %s not found", name) //nolint:err113 + } + + summary.WithLabelValues(labels...).Observe(value) + + return nil +} + +// GetRegistry returns the Prometheus registry for custom integrations +func (mc *MetricsCollector) GetRegistry() *prometheus.Registry { + return mc.registry +} + // responseWriter wraps http.ResponseWriter to capture status code type responseWriter struct { http.ResponseWriter @@ -113,42 +389,95 @@ func GetMetrics(r *http.Request) *MetricsCollector { return metrics } -// IncCounter increments a counter metric -func IncCounter(r *http.Request, name string, labels ...string) { +// Helper functions for easy metric manipulation from handlers + +// IncCounter increments a counter metric from a request context +func IncCounter(r *http.Request, name string, labels ...string) error { metrics := GetMetrics(r) if metrics == nil { - return + return errors.New("metrics not available in request context") //nolint:err113 } - // This is a simplified version - in a real implementation, - // you'd want to have a more flexible metric registration system - switch name { - case "http_requests_total": - if len(labels) >= 3 { - metrics.httpRequestsTotal.WithLabelValues(labels[0], labels[1], labels[2]).Inc() - } + return metrics.IncCounter(name, labels...) +} + +// AddCounter adds a value to a counter metric from a request context +func AddCounter(r *http.Request, name string, value float64, labels ...string) error { + metrics := GetMetrics(r) + if metrics == nil { + return errors.New("metrics not available in request context") //nolint:err113 + } + + return metrics.AddCounter(name, value, labels...) +} + +// SetGauge sets a gauge metric value from a request context +func SetGauge(r *http.Request, name string, value float64, labels ...string) error { + metrics := GetMetrics(r) + if metrics == nil { + return errors.New("metrics not available in request context") //nolint:err113 + } + + return metrics.SetGauge(name, value, labels...) +} + +// IncGauge increments a gauge metric from a request context +func IncGauge(r *http.Request, name string, labels ...string) error { + metrics := GetMetrics(r) + if metrics == nil { + return errors.New("metrics not available in request context") //nolint:err113 + } + + return metrics.IncGauge(name, labels...) +} + +// DecGauge decrements a gauge metric from a request context +func DecGauge(r *http.Request, name string, labels ...string) error { + metrics := GetMetrics(r) + if metrics == nil { + return errors.New("metrics not available in request context") //nolint:err113 } + + return metrics.DecGauge(name, labels...) } -// ObserveHistogram observes a histogram metric -func ObserveHistogram(r *http.Request, name string, value float64, labels ...string) { +// AddGauge adds a value to a gauge metric from a request context +func AddGauge(r *http.Request, name string, value float64, labels ...string) error { metrics := GetMetrics(r) if metrics == nil { - return + return errors.New("metrics not available in request context") //nolint:err113 } - switch name { - case "http_request_duration_seconds": - if len(labels) >= 3 { - metrics.httpRequestDuration.WithLabelValues(labels[0], labels[1], labels[2]).Observe(value) - } + return metrics.AddGauge(name, value, labels...) +} + +// ObserveHistogram observes a value in a histogram metric from a request context +func ObserveHistogram(r *http.Request, name string, value float64, labels ...string) error { + metrics := GetMetrics(r) + if metrics == nil { + return errors.New("metrics not available in request context") //nolint:err113 } + + return metrics.ObserveHistogram(name, value, labels...) +} + +// ObserveSummary observes a value in a summary metric from a request context +func ObserveSummary(r *http.Request, name string, value float64, labels ...string) error { + metrics := GetMetrics(r) + if metrics == nil { + return errors.New("metrics not available in request context") //nolint:err113 + } + + return metrics.ObserveSummary(name, value, labels...) } // startMetricsServer starts the Prometheus metrics server func (s *Service) startMetricsServer() error { mux := http.NewServeMux() - mux.Handle(s.Config.MetricsPath, promhttp.Handler()) + + // Use the custom registry from metrics collector + handler := promhttp.HandlerFor(s.Metrics.GetRegistry(), promhttp.HandlerOpts{}) + mux.Handle(s.Config.MetricsPath, handler) // Add health check endpoints if s.HealthChecker != nil { diff --git a/metrics_test.go b/metrics_test.go new file mode 100644 index 0000000..b687671 --- /dev/null +++ b/metrics_test.go @@ -0,0 +1,774 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" + dto "github.com/prometheus/client_model/go" +) + +func TestNewMetricsCollector(t *testing.T) { + t.Parallel() + + metrics := NewMetricsCollector("test-service") + + if metrics == nil { + t.Fatal("expected metrics collector to be created") + } + + if metrics.serviceName != "test-service" { + t.Errorf("expected service name 'test-service', got %s", metrics.serviceName) + } + + if metrics.registry == nil { + t.Fatal("expected registry to be created") + } + + if metrics.counters == nil { + t.Fatal("expected counters map to be initialized") + } + + if metrics.gauges == nil { + t.Fatal("expected gauges map to be initialized") + } + + if metrics.histograms == nil { + t.Fatal("expected histograms map to be initialized") + } + + if metrics.summaries == nil { + t.Fatal("expected summaries map to be initialized") + } + + // Check that built-in HTTP metrics are created + if metrics.httpRequestsTotal == nil { + t.Fatal("expected HTTP requests total metric to be created") + } + + if metrics.httpRequestDuration == nil { + t.Fatal("expected HTTP request duration metric to be created") + } + + if metrics.httpRequestsInFlight == nil { + t.Fatal("expected HTTP requests in flight metric to be created") + } +} + +func TestMetricsCollector_RegisterCounter(t *testing.T) { + t.Parallel() + + metrics := NewMetricsCollector("test-service") + + t.Run("registers counter successfully", func(t *testing.T) { + t.Parallel() + + config := MetricConfig{ + Name: "test_counter", + Help: "Test counter metric", + Labels: []string{"label1", "label2"}, + } + + err := metrics.RegisterCounter(config) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if _, exists := metrics.counters["test_counter"]; !exists { + t.Error("expected counter to be registered") + } + }) + + t.Run("fails to register duplicate counter", func(t *testing.T) { + t.Parallel() + + config := MetricConfig{ + Name: "duplicate_counter", + Help: "Duplicate counter metric", + Labels: []string{"label1"}, + } + + err := metrics.RegisterCounter(config) + if err != nil { + t.Fatalf("expected no error on first registration, got %v", err) + } + + err = metrics.RegisterCounter(config) + if err == nil { + t.Error("expected error on duplicate registration") + } + + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("expected 'already exists' error, got %v", err) + } + }) +} + +func TestMetricsCollector_RegisterGauge(t *testing.T) { + t.Parallel() + + metrics := NewMetricsCollector("test-service") + + config := MetricConfig{ + Name: "test_gauge", + Help: "Test gauge metric", + Labels: []string{"label1"}, + } + + err := metrics.RegisterGauge(config) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if _, exists := metrics.gauges["test_gauge"]; !exists { + t.Error("expected gauge to be registered") + } +} + +func TestMetricsCollector_RegisterHistogram(t *testing.T) { + t.Parallel() + + metrics := NewMetricsCollector("test-service") + + t.Run("registers histogram with default buckets", func(t *testing.T) { + t.Parallel() + + config := MetricConfig{ + Name: "test_histogram", + Help: "Test histogram metric", + Labels: []string{"label1"}, + } + + err := metrics.RegisterHistogram(config) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if _, exists := metrics.histograms["test_histogram"]; !exists { + t.Error("expected histogram to be registered") + } + }) + + t.Run("registers histogram with custom buckets", func(t *testing.T) { + t.Parallel() + + config := MetricConfig{ + Name: "test_histogram_custom", + Help: "Test histogram with custom buckets", + Labels: []string{"label1"}, + Buckets: []float64{0.1, 0.5, 1.0, 5.0, 10.0}, + } + + err := metrics.RegisterHistogram(config) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if _, exists := metrics.histograms["test_histogram_custom"]; !exists { + t.Error("expected histogram to be registered") + } + }) +} + +func TestMetricsCollector_RegisterSummary(t *testing.T) { + t.Parallel() + + metrics := NewMetricsCollector("test-service") + + t.Run("registers summary with default objectives", func(t *testing.T) { + t.Parallel() + + config := MetricConfig{ + Name: "test_summary", + Help: "Test summary metric", + Labels: []string{"label1"}, + } + + err := metrics.RegisterSummary(config) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if _, exists := metrics.summaries["test_summary"]; !exists { + t.Error("expected summary to be registered") + } + }) + + t.Run("registers summary with custom objectives", func(t *testing.T) { + t.Parallel() + + config := MetricConfig{ + Name: "test_summary_custom", + Help: "Test summary with custom objectives", + Labels: []string{"label1"}, + Objectives: map[float64]float64{0.5: 0.05, 0.95: 0.01}, + } + + err := metrics.RegisterSummary(config) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if _, exists := metrics.summaries["test_summary_custom"]; !exists { + t.Error("expected summary to be registered") + } + }) +} + +func TestMetricsCollector_CounterOperations(t *testing.T) { + t.Parallel() + + metrics := NewMetricsCollector("test-service") + + // Register a counter + config := MetricConfig{ + Name: "test_counter_ops", + Help: "Test counter operations", + Labels: []string{"operation"}, + } + + err := metrics.RegisterCounter(config) + if err != nil { + t.Fatalf("failed to register counter: %v", err) + } + + t.Run("increments counter", func(t *testing.T) { + t.Parallel() + + err := metrics.IncCounter("test_counter_ops", "inc") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Verify the counter was incremented + counter := metrics.counters["test_counter_ops"] + metric := &dto.Metric{} + + err = counter.WithLabelValues("inc").Write(metric) + if err != nil { + t.Fatalf("failed to write metric: %v", err) + } + + if metric.GetCounter().GetValue() != 1 { + t.Errorf("expected counter value 1, got %f", metric.GetCounter().GetValue()) + } + }) + + t.Run("adds value to counter", func(t *testing.T) { + t.Parallel() + + err := metrics.AddCounter("test_counter_ops", 5.5, "add") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Verify the counter was incremented by 5.5 + counter := metrics.counters["test_counter_ops"] + metric := &dto.Metric{} + + err = counter.WithLabelValues("add").Write(metric) + if err != nil { + t.Fatalf("failed to write metric: %v", err) + } + + if metric.GetCounter().GetValue() != 5.5 { + t.Errorf("expected counter value 5.5, got %f", metric.GetCounter().GetValue()) + } + }) + + t.Run("fails with non-existent counter", func(t *testing.T) { + t.Parallel() + + err := metrics.IncCounter("non_existent_counter", "test") + if err == nil { + t.Error("expected error for non-existent counter") + } + + if !strings.Contains(err.Error(), "not found") { + t.Errorf("expected 'not found' error, got %v", err) + } + }) +} + +//nolint:gocognit +func TestMetricsCollector_GaugeOperations(t *testing.T) { + t.Parallel() + + metrics := NewMetricsCollector("test-service") + + // Register a gauge + config := MetricConfig{ + Name: "test_gauge_ops", + Help: "Test gauge operations", + Labels: []string{"operation"}, + } + + err := metrics.RegisterGauge(config) + if err != nil { + t.Fatalf("failed to register gauge: %v", err) + } + + t.Run("sets gauge value", func(t *testing.T) { + t.Parallel() + + err := metrics.SetGauge("test_gauge_ops", 42.5, "set") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Verify the gauge value + gauge := metrics.gauges["test_gauge_ops"] + metric := &dto.Metric{} + + err = gauge.WithLabelValues("set").Write(metric) + if err != nil { + t.Fatalf("failed to write metric: %v", err) + } + + if metric.GetGauge().GetValue() != 42.5 { + t.Errorf("expected gauge value 42.5, got %f", metric.GetGauge().GetValue()) + } + }) + + t.Run("increments gauge", func(t *testing.T) { + // First set a value + t.Parallel() + + err := metrics.SetGauge("test_gauge_ops", 10, "inc") + if err != nil { + t.Fatalf("failed to set initial gauge value: %v", err) + } + + err = metrics.IncGauge("test_gauge_ops", "inc") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Verify the gauge was incremented + gauge := metrics.gauges["test_gauge_ops"] + metric := &dto.Metric{} + + err = gauge.WithLabelValues("inc").Write(metric) + if err != nil { + t.Fatalf("failed to write metric: %v", err) + } + + if metric.GetGauge().GetValue() != 11 { + t.Errorf("expected gauge value 11, got %f", metric.GetGauge().GetValue()) + } + }) + + t.Run("decrements gauge", func(t *testing.T) { + // First set a value + t.Parallel() + + err := metrics.SetGauge("test_gauge_ops", 10, "dec") + if err != nil { + t.Fatalf("failed to set initial gauge value: %v", err) + } + + err = metrics.DecGauge("test_gauge_ops", "dec") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Verify the gauge was decremented + gauge := metrics.gauges["test_gauge_ops"] + metric := &dto.Metric{} + + err = gauge.WithLabelValues("dec").Write(metric) + if err != nil { + t.Fatalf("failed to write metric: %v", err) + } + + if metric.GetGauge().GetValue() != 9 { + t.Errorf("expected gauge value 9, got %f", metric.GetGauge().GetValue()) + } + }) + + t.Run("adds value to gauge", func(t *testing.T) { + // First set a value + t.Parallel() + + err := metrics.SetGauge("test_gauge_ops", 10, "add") + if err != nil { + t.Fatalf("failed to set initial gauge value: %v", err) + } + + err = metrics.AddGauge("test_gauge_ops", 5.5, "add") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Verify the gauge value + gauge := metrics.gauges["test_gauge_ops"] + metric := &dto.Metric{} + + err = gauge.WithLabelValues("add").Write(metric) + if err != nil { + t.Fatalf("failed to write metric: %v", err) + } + + if metric.GetGauge().GetValue() != 15.5 { + t.Errorf("expected gauge value 15.5, got %f", metric.GetGauge().GetValue()) + } + }) +} + +func TestMetricsCollector_HistogramOperations(t *testing.T) { + t.Parallel() + + metrics := NewMetricsCollector("test-service") + + // Register a histogram + config := MetricConfig{ + Name: "test_histogram_ops", + Help: "Test histogram operations", + Labels: []string{"operation"}, + } + + err := metrics.RegisterHistogram(config) + if err != nil { + t.Fatalf("failed to register histogram: %v", err) + } + + t.Run("observes histogram values", func(t *testing.T) { + t.Parallel() + + values := []float64{0.1, 0.5, 1.0, 2.0, 5.0} + for _, value := range values { + err := metrics.ObserveHistogram("test_histogram_ops", value, "observe") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + } + + // Verify the histogram recorded the observations by checking the registry + registry := metrics.GetRegistry() + + metricFamilies, err := registry.Gather() + if err != nil { + t.Fatalf("failed to gather metrics: %v", err) + } + + found := false + + for _, mf := range metricFamilies { + if mf.GetName() == "test_histogram_ops" { + found = true + + for _, metric := range mf.GetMetric() { + if metric.GetHistogram().GetSampleCount() == uint64(len(values)) { + return // Test passed + } + } + } + } + + if !found { + t.Error("expected to find histogram metric") + } + }) +} + +func TestMetricsCollector_SummaryOperations(t *testing.T) { + t.Parallel() + + metrics := NewMetricsCollector("test-service") + + // Register a summary + config := MetricConfig{ + Name: "test_summary_ops", + Help: "Test summary operations", + Labels: []string{"operation"}, + } + + err := metrics.RegisterSummary(config) + if err != nil { + t.Fatalf("failed to register summary: %v", err) + } + + t.Run("observes summary values", func(t *testing.T) { + t.Parallel() + + values := []float64{0.1, 0.5, 1.0, 2.0, 5.0} + for _, value := range values { + err := metrics.ObserveSummary("test_summary_ops", value, "observe") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + } + + // Verify the summary recorded the observations by checking the registry + registry := metrics.GetRegistry() + + metricFamilies, err := registry.Gather() + if err != nil { + t.Fatalf("failed to gather metrics: %v", err) + } + + found := false + + for _, mf := range metricFamilies { + if mf.GetName() == "test_summary_ops" { + found = true + + for _, metric := range mf.GetMetric() { + if metric.GetSummary().GetSampleCount() == uint64(len(values)) { + return // Test passed + } + } + } + } + + if !found { + t.Error("expected to find summary metric") + } + }) +} + +func TestHelperFunctions(t *testing.T) { + t.Parallel() + + svc := New("test-service", nil) + + // Register custom metrics + err := svc.RegisterCounter(MetricConfig{ + Name: "test_helper_counter", + Help: "Test helper counter", + Labels: []string{"action"}, + }) + if err != nil { + t.Fatalf("failed to register counter: %v", err) + } + + err = svc.RegisterGauge(MetricConfig{ + Name: "test_helper_gauge", + Help: "Test helper gauge", + Labels: []string{"action"}, + }) + if err != nil { + t.Fatalf("failed to register gauge: %v", err) + } + + // Test helper functions with middleware + handler := applyMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Test counter helpers + err := IncCounter(r, "test_helper_counter", "increment") + if err != nil { + t.Errorf("IncCounter failed: %v", err) + } + + err = AddCounter(r, "test_helper_counter", 5.0, "add") + if err != nil { + t.Errorf("AddCounter failed: %v", err) + } + + // Test gauge helpers + err = SetGauge(r, "test_helper_gauge", 42.0, "set") + if err != nil { + t.Errorf("SetGauge failed: %v", err) + } + + err = IncGauge(r, "test_helper_gauge", "increment") + if err != nil { + t.Errorf("IncGauge failed: %v", err) + } + + w.WriteHeader(http.StatusOK) + }), MetricsMiddleware(svc.Metrics)) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } +} + +func TestHelperFunctionsWithoutMetrics(t *testing.T) { + t.Parallel() + + // Test helper functions without metrics in context + req := httptest.NewRequest(http.MethodGet, "/test", nil) + + err := IncCounter(req, "test_counter", "test") + if err == nil { + t.Error("expected error when metrics not available") + } + + if !strings.Contains(err.Error(), "not available") { + t.Errorf("expected 'not available' error, got %v", err) + } +} + +func TestMetricsMiddleware_CustomMetrics(t *testing.T) { + t.Parallel() + + svc := New("test-service", nil) + + handler := applyMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate some processing time + time.Sleep(10 * time.Millisecond) + w.WriteHeader(http.StatusOK) + w.Write([]byte("test")) + }), MetricsMiddleware(svc.Metrics)) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } + + // Verify metrics were recorded + registry := svc.Metrics.GetRegistry() + + metricFamilies, err := registry.Gather() + if err != nil { + t.Fatalf("failed to gather metrics: %v", err) + } + + // Check that we have the expected metrics + foundRequestsTotal := false + foundRequestDuration := false + foundRequestsInFlight := false + + for _, mf := range metricFamilies { + switch mf.GetName() { + case "test-service_http_requests_total": + foundRequestsTotal = true + case "test-service_http_request_duration_seconds": + foundRequestDuration = true + case "test-service_http_requests_in_flight": + foundRequestsInFlight = true + } + } + + if !foundRequestsTotal { + t.Error("expected to find http_requests_total metric") + } + + if !foundRequestDuration { + t.Error("expected to find http_request_duration_seconds metric") + } + + if !foundRequestsInFlight { + t.Error("expected to find http_requests_in_flight metric") + } +} + +func TestMetricsRegistry(t *testing.T) { + t.Parallel() + + metrics := NewMetricsCollector("test-service") + + // Test that we can get the registry + registry := metrics.GetRegistry() + if registry == nil { + t.Fatal("expected registry to be returned") + } + + // Test that we can create a custom handler + handler := promhttp.HandlerFor(registry, promhttp.HandlerOpts{}) + if handler == nil { + t.Fatal("expected handler to be created") + } + + // Test that the handler works + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", recorder.Code) + } + + // Check that the response contains metrics + body := recorder.Body.String() + if !strings.Contains(body, "test_service_http_requests_in_flight") { + t.Errorf("expected metrics output to contain http_requests_in_flight, got: %s", body) + } +} + +func TestService_RegisterMetrics(t *testing.T) { + t.Parallel() + + svc := New("test-service", nil) + + t.Run("registers counter via service", func(t *testing.T) { + t.Parallel() + + err := svc.RegisterCounter(MetricConfig{ + Name: "service_test_counter", + Help: "Test counter via service", + Labels: []string{"label1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if _, exists := svc.Metrics.counters["service_test_counter"]; !exists { + t.Error("expected counter to be registered") + } + }) + + t.Run("registers gauge via service", func(t *testing.T) { + t.Parallel() + + err := svc.RegisterGauge(MetricConfig{ + Name: "service_test_gauge", + Help: "Test gauge via service", + Labels: []string{"label1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if _, exists := svc.Metrics.gauges["service_test_gauge"]; !exists { + t.Error("expected gauge to be registered") + } + }) + + t.Run("registers histogram via service", func(t *testing.T) { + t.Parallel() + + err := svc.RegisterHistogram(MetricConfig{ + Name: "service_test_histogram", + Help: "Test histogram via service", + Labels: []string{"label1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if _, exists := svc.Metrics.histograms["service_test_histogram"]; !exists { + t.Error("expected histogram to be registered") + } + }) + + t.Run("registers summary via service", func(t *testing.T) { + t.Parallel() + + err := svc.RegisterSummary(MetricConfig{ + Name: "service_test_summary", + Help: "Test summary via service", + Labels: []string{"label1"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if _, exists := svc.Metrics.summaries["service_test_summary"]; !exists { + t.Error("expected summary to be registered") + } + }) +} diff --git a/service.go b/service.go index e7d3a3d..12f9e13 100644 --- a/service.go +++ b/service.go @@ -147,6 +147,26 @@ func (s *Service) RegisterHealthCheck(config health.Config) error { return nil } +// RegisterCounter registers a new counter metric +func (s *Service) RegisterCounter(config MetricConfig) error { + return s.Metrics.RegisterCounter(config) +} + +// RegisterGauge registers a new gauge metric +func (s *Service) RegisterGauge(config MetricConfig) error { + return s.Metrics.RegisterGauge(config) +} + +// RegisterHistogram registers a new histogram metric +func (s *Service) RegisterHistogram(config MetricConfig) error { + return s.Metrics.RegisterHistogram(config) +} + +// RegisterSummary registers a new summary metric +func (s *Service) RegisterSummary(config MetricConfig) error { + return s.Metrics.RegisterSummary(config) +} + // GetHealthChecker returns the health checker instance func (s *Service) GetHealthChecker() *HealthChecker { return s.HealthChecker diff --git a/service_test.go b/service_test.go index fc88404..22fb3ff 100644 --- a/service_test.go +++ b/service_test.go @@ -78,8 +78,6 @@ func TestDefaultConfig(t *testing.T) { } func TestLoadFromEnv(t *testing.T) { - t.Parallel() - // Set environment variables t.Setenv("ADDR", ":8888") t.Setenv("METRICS_ADDR", ":9999") From 38198c0ff4af5e96c25c97e86e0c102f0da5b15a Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Sat, 12 Jul 2025 02:40:58 +0200 Subject: [PATCH 14/17] fix: update metric registration to include service name prefix - Modified metric registration functions to ensure that metric names are prefixed with the service name. - Updated test cases to reflect the new metric naming convention. - Enhanced README to clarify the automatic collection of service-specific metrics. --- README.md | 78 +----- _examples/custom-metrics/main.go | 382 --------------------------- _examples/prometheus-counter/main.go | 261 +----------------- metrics.go | 117 +++++--- metrics_test.go | 36 +-- 5 files changed, 114 insertions(+), 760 deletions(-) delete mode 100644 _examples/custom-metrics/main.go diff --git a/README.md b/README.md index 4396fd2..21c26d8 100644 --- a/README.md +++ b/README.md @@ -184,11 +184,13 @@ The framework provides a flexible metrics system with built-in HTTP metrics and ### Built-in HTTP Metrics -The framework automatically collects these Prometheus metrics: +The framework automatically collects these Prometheus metrics for every service: -- `{service_name}_http_requests_total`: Total HTTP requests -- `{service_name}_http_request_duration_seconds`: Request duration -- `{service_name}_http_requests_in_flight`: In-flight requests +- `{service_name}_http_requests_total`: Total HTTP requests by method, endpoint, and status +- `{service_name}_http_request_duration_seconds`: Request duration histogram by method, endpoint, and status +- `{service_name}_http_requests_in_flight`: Current number of in-flight requests + +These metrics are provided automatically without any configuration required. ### Custom Metrics @@ -255,28 +257,6 @@ The framework supports all standard Prometheus metric types: - **Histogram**: Observations in configurable buckets (e.g., request duration, response size) - **Summary**: Observations with configurable quantiles (e.g., request latency percentiles) -### Helper Functions - -Use these helper functions in your HTTP handlers to manipulate metrics: - -```go -// Counter operations -service.IncCounter(r, "metric_name", "label1", "label2") // Increment by 1 -service.AddCounter(r, "metric_name", 5.0, "label1", "label2") // Add specific value - -// Gauge operations -service.SetGauge(r, "metric_name", 42.0, "label1") // Set to specific value -service.IncGauge(r, "metric_name", "label1") // Increment by 1 -service.DecGauge(r, "metric_name", "label1") // Decrement by 1 -service.AddGauge(r, "metric_name", 5.0, "label1") // Add specific value - -// Histogram operations -service.ObserveHistogram(r, "metric_name", 0.25, "label1") // Observe a value - -// Summary operations -service.ObserveSummary(r, "metric_name", 1024.0, "label1") // Observe a value -``` - ### Direct Access For advanced use cases, you can access the metrics collector directly: @@ -449,54 +429,10 @@ See the `_examples/` directory for complete working examples demonstrating: - **minimal/**: Basic service setup with default configuration - **custom-metrics/**: Comprehensive custom metrics registration and usage -- **prometheus-counter/**: Advanced metrics patterns and business logic tracking +- **prometheus-counter/**: Simple custom counter example (HTTP metrics are automatic) - **health-check-***: Various health check integrations - **shutdown-hook/**: Graceful shutdown with custom cleanup -### Running the Examples - -```bash -# Basic minimal service -cd _examples/minimal -go run main.go - -# Custom metrics demonstration -cd _examples/custom-metrics -go run main.go - -# Advanced Prometheus metrics -cd _examples/prometheus-counter -go run main.go -``` - -### Testing Custom Metrics - -After running the custom metrics example: - -```bash -# Generate user registrations -curl "http://localhost:8080/register?source=web" -curl "http://localhost:8080/register?source=mobile" - -# Generate orders -curl "http://localhost:8080/order?category=electronics&payment=credit_card" -curl "http://localhost:8080/order?category=books&payment=paypal" - -# Admin metric operations -curl -X POST "http://localhost:8080/admin/metrics?action=add_users" -curl -X POST "http://localhost:8080/admin/metrics?action=clear_queue&queue=email" - -# Check all endpoints -curl http://localhost:8080/status -curl http://localhost:9090/health # Comprehensive health check -curl http://localhost:9090/ready # Readiness probe -curl http://localhost:9090/live # Liveness probe -curl http://localhost:9090/metrics # Prometheus metrics - -# Filter custom metrics -curl http://localhost:9090/metrics | grep -E "(user_registrations|orders_total|active_users)" -``` - ## Best Practices 1. **Minimal setup** - Start with default configuration and customize only what you need diff --git a/_examples/custom-metrics/main.go b/_examples/custom-metrics/main.go deleted file mode 100644 index 40d197c..0000000 --- a/_examples/custom-metrics/main.go +++ /dev/null @@ -1,382 +0,0 @@ -package main - -import ( - "fmt" - "math/rand" - "net/http" - "os" - "time" - - "atomicgo.dev/service" -) - -func main() { - svc := service.New("custom-metrics-service", nil) - - // Register custom business metrics - err := svc.RegisterCounter(service.MetricConfig{ - Name: "user_registrations_total", - Help: "Total number of user registrations", - Labels: []string{"source", "status"}, - }) - if err != nil { - svc.Logger.Error("Failed to register user registrations counter", "error", err) - os.Exit(1) - } - - err = svc.RegisterCounter(service.MetricConfig{ - Name: "orders_total", - Help: "Total number of orders placed", - Labels: []string{"product_category", "payment_method"}, - }) - if err != nil { - svc.Logger.Error("Failed to register orders counter", "error", err) - os.Exit(1) - } - - err = svc.RegisterGauge(service.MetricConfig{ - Name: "active_users", - Help: "Number of currently active users", - Labels: []string{"user_type"}, - }) - if err != nil { - svc.Logger.Error("Failed to register active users gauge", "error", err) - os.Exit(1) - } - - err = svc.RegisterGauge(service.MetricConfig{ - Name: "queue_size", - Help: "Current size of processing queues", - Labels: []string{"queue_name"}, - }) - if err != nil { - svc.Logger.Error("Failed to register queue size gauge", "error", err) - os.Exit(1) - } - - err = svc.RegisterHistogram(service.MetricConfig{ - Name: "request_processing_duration_seconds", - Help: "Time spent processing business requests", - Labels: []string{"operation", "result"}, - Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 2.0, 5.0}, - }) - if err != nil { - svc.Logger.Error("Failed to register request processing duration histogram", "error", err) - os.Exit(1) - } - - err = svc.RegisterSummary(service.MetricConfig{ - Name: "response_size_bytes", - Help: "Size of API responses in bytes", - Labels: []string{"endpoint", "content_type"}, - Objectives: map[float64]float64{ - 0.5: 0.05, - 0.9: 0.01, - 0.95: 0.005, - 0.99: 0.001, - }, - }) - if err != nil { - svc.Logger.Error("Failed to register response size summary", "error", err) - os.Exit(1) - } - - // Simulate background metric updates - go func() { - ticker := time.NewTicker(3 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - // Simulate changing active users - premiumUsers := rand.Intn(50) + 20 - freeUsers := rand.Intn(200) + 100 - - svc.Metrics.SetGauge("active_users", float64(premiumUsers), "premium") - svc.Metrics.SetGauge("active_users", float64(freeUsers), "free") - - // Simulate queue sizes - svc.Metrics.SetGauge("queue_size", float64(rand.Intn(20)), "email") - svc.Metrics.SetGauge("queue_size", float64(rand.Intn(50)), "notifications") - svc.Metrics.SetGauge("queue_size", float64(rand.Intn(30)), "analytics") - - svc.Logger.Info("Updated background metrics", - "premium_users", premiumUsers, - "free_users", freeUsers) - } - } - }() - - // User registration endpoint - svc.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - start := time.Now() - - // Simulate registration logic - source := r.URL.Query().Get("source") - if source == "" { - source = "web" - } - - // Simulate random success/failure - success := rand.Float32() > 0.1 // 90% success rate - status := "success" - if !success { - status = "failure" - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Registration failed")) - } else { - w.WriteHeader(http.StatusOK) - w.Write([]byte("Registration successful")) - } - - // Record metrics - err := service.IncCounter(r, "user_registrations_total", source, status) - if err != nil { - logger.Error("Failed to increment user registrations counter", "error", err) - } - - // Record processing time - duration := time.Since(start).Seconds() - err = service.ObserveHistogram(r, "request_processing_duration_seconds", duration, "registration", status) - if err != nil { - logger.Error("Failed to observe processing duration", "error", err) - } - - // Record response size - responseSize := float64(len("Registration successful")) - err = service.ObserveSummary(r, "response_size_bytes", responseSize, "/register", "text/plain") - if err != nil { - logger.Error("Failed to observe response size", "error", err) - } - - logger.Info("User registration processed", - "source", source, - "status", status, - "duration", duration) - }) - - // Order placement endpoint - svc.HandleFunc("/order", func(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - start := time.Now() - - // Get parameters - category := r.URL.Query().Get("category") - if category == "" { - category = "electronics" - } - - paymentMethod := r.URL.Query().Get("payment") - if paymentMethod == "" { - paymentMethod = "credit_card" - } - - // Simulate order processing - time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond) - - // Simulate random success/failure - success := rand.Float32() > 0.05 // 95% success rate - status := "success" - if !success { - status = "failure" - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Order failed")) - } else { - w.WriteHeader(http.StatusOK) - w.Write([]byte(fmt.Sprintf("Order placed for %s via %s", category, paymentMethod))) - } - - // Record metrics - err := service.IncCounter(r, "orders_total", category, paymentMethod) - if err != nil { - logger.Error("Failed to increment orders counter", "error", err) - } - - // Record processing time - duration := time.Since(start).Seconds() - err = service.ObserveHistogram(r, "request_processing_duration_seconds", duration, "order", status) - if err != nil { - logger.Error("Failed to observe processing duration", "error", err) - } - - // Record response size - responseContent := fmt.Sprintf("Order placed for %s via %s", category, paymentMethod) - responseSize := float64(len(responseContent)) - err = service.ObserveSummary(r, "response_size_bytes", responseSize, "/order", "text/plain") - if err != nil { - logger.Error("Failed to observe response size", "error", err) - } - - logger.Info("Order processed", - "category", category, - "payment_method", paymentMethod, - "status", status, - "duration", duration) - }) - - // Admin endpoint to manually update metrics - svc.HandleFunc("/admin/metrics", func(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - - switch r.Method { - case http.MethodPost: - // Simulate admin actions that affect metrics - action := r.URL.Query().Get("action") - - switch action { - case "add_users": - // Simulate adding users - count := rand.Intn(10) + 1 - err := service.AddGauge(r, "active_users", float64(count), "premium") - if err != nil { - logger.Error("Failed to add to active users gauge", "error", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - w.Write([]byte(fmt.Sprintf("Added %d premium users", count))) - - case "clear_queue": - // Simulate clearing a queue - queueName := r.URL.Query().Get("queue") - if queueName == "" { - queueName = "email" - } - err := service.SetGauge(r, "queue_size", 0, queueName) - if err != nil { - logger.Error("Failed to clear queue gauge", "error", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - w.Write([]byte(fmt.Sprintf("Cleared %s queue", queueName))) - - default: - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("Unknown action")) - } - - default: - w.WriteHeader(http.StatusMethodNotAllowed) - w.Write([]byte("Method not allowed")) - } - }) - - // Status endpoint showing current metrics - svc.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - logger.Info("Status endpoint requested") - - w.Header().Set("Content-Type", "text/plain") - w.Write([]byte("Custom Metrics Demo Service Status\n")) - w.Write([]byte("==================================\n\n")) - w.Write([]byte("Available endpoints:\n")) - w.Write([]byte("- GET /register?source=web|mobile|api\n")) - w.Write([]byte("- GET /order?category=electronics|books|clothing&payment=credit_card|paypal|crypto\n")) - w.Write([]byte("- POST /admin/metrics?action=add_users|clear_queue&queue=email|notifications|analytics\n")) - w.Write([]byte("- GET /status (this endpoint)\n\n")) - w.Write([]byte("Metrics available at: http://localhost:9090/metrics\n\n")) - w.Write([]byte("Custom metrics registered:\n")) - w.Write([]byte("- user_registrations_total: Counter tracking user registrations by source and status\n")) - w.Write([]byte("- orders_total: Counter tracking orders by category and payment method\n")) - w.Write([]byte("- active_users: Gauge showing current active users by type\n")) - w.Write([]byte("- queue_size: Gauge showing current queue sizes\n")) - w.Write([]byte("- request_processing_duration_seconds: Histogram of request processing times\n")) - w.Write([]byte("- response_size_bytes: Summary of response sizes\n")) - w.Write([]byte("\nBuilt-in HTTP metrics are also available (requests_total, request_duration, etc.)\n")) - }) - - // Root endpoint - svc.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - logger.Info("Root endpoint accessed") - - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(` - - - - Custom Metrics Demo - - - -

Custom Metrics Demo Service

-

This service demonstrates the new flexible metrics system with custom metrics registration and manipulation.

- -
- GET /register?source=web|mobile|api -
Simulates user registration and tracks metrics by source and status.
-
- -
- GET /order?category=electronics|books|clothing&payment=credit_card|paypal|crypto -
Simulates order placement and tracks metrics by category and payment method.
-
- -
- POST /admin/metrics?action=add_users|clear_queue&queue=email|notifications|analytics -
Admin endpoint to manually manipulate metrics.
-
- -
- GET /status -
Shows service status and available endpoints.
-
- - View Prometheus Metrics - -

Try these commands:

-
-# Generate some user registrations
-curl "http://localhost:8080/register?source=web"
-curl "http://localhost:8080/register?source=mobile"
-curl "http://localhost:8080/register?source=api"
-
-# Generate some orders
-curl "http://localhost:8080/order?category=electronics&payment=credit_card"
-curl "http://localhost:8080/order?category=books&payment=paypal"
-curl "http://localhost:8080/order?category=clothing&payment=crypto"
-
-# Admin actions
-curl -X POST "http://localhost:8080/admin/metrics?action=add_users"
-curl -X POST "http://localhost:8080/admin/metrics?action=clear_queue&queue=email"
-
-# Check metrics
-curl http://localhost:9090/metrics | grep -E "(user_registrations|orders_total|active_users|queue_size)"
-    
- - - `)) - }) - - svc.Logger.Info("Starting Custom Metrics Demo Service...") - svc.Logger.Info("Service available at http://localhost:8080") - svc.Logger.Info("Prometheus metrics at http://localhost:9090/metrics") - svc.Logger.Info("") - svc.Logger.Info("Custom metrics registered:") - svc.Logger.Info("- user_registrations_total: Counter for user registrations") - svc.Logger.Info("- orders_total: Counter for orders placed") - svc.Logger.Info("- active_users: Gauge for active users") - svc.Logger.Info("- queue_size: Gauge for queue sizes") - svc.Logger.Info("- request_processing_duration_seconds: Histogram for processing times") - svc.Logger.Info("- response_size_bytes: Summary for response sizes") - svc.Logger.Info("") - svc.Logger.Info("Try the endpoints:") - svc.Logger.Info(" curl 'http://localhost:8080/register?source=web'") - svc.Logger.Info(" curl 'http://localhost:8080/order?category=electronics&payment=credit_card'") - svc.Logger.Info(" curl -X POST 'http://localhost:8080/admin/metrics?action=add_users'") - svc.Logger.Info(" curl http://localhost:8080/status") - - if err := svc.Start(); err != nil { - svc.Logger.Error("Failed to start service", "error", err) - os.Exit(1) - } -} diff --git a/_examples/prometheus-counter/main.go b/_examples/prometheus-counter/main.go index f008df5..0521620 100644 --- a/_examples/prometheus-counter/main.go +++ b/_examples/prometheus-counter/main.go @@ -1,12 +1,8 @@ package main import ( - "fmt" - "math/rand" "net/http" "os" - "strconv" - "time" "atomicgo.dev/service" ) @@ -14,273 +10,32 @@ import ( func main() { svc := service.New("prometheus-counter-service", nil) - // Register custom metrics using the new flexible system + // Register a simple custom counter to demonstrate the metrics system err := svc.RegisterCounter(service.MetricConfig{ - Name: "myapp_requests_total", - Help: "Total number of requests processed", - Labels: []string{"method", "endpoint", "status"}, - }) - if err != nil { - svc.Logger.Error("Failed to register requests counter", "error", err) - os.Exit(1) - } - - err = svc.RegisterCounter(service.MetricConfig{ - Name: "myapp_business_events_total", - Help: "Total number of business events processed", + Name: "demo_counter", + Help: "Total number of demo events processed", Labels: []string{"event_type", "result"}, }) if err != nil { - svc.Logger.Error("Failed to register business events counter", "error", err) - os.Exit(1) - } - - err = svc.RegisterGauge(service.MetricConfig{ - Name: "myapp_active_users", - Help: "Number of currently active users", - Labels: []string{}, // No labels for this gauge - }) - if err != nil { - svc.Logger.Error("Failed to register active users gauge", "error", err) - os.Exit(1) - } - - err = svc.RegisterGauge(service.MetricConfig{ - Name: "myapp_queue_size", - Help: "Current size of processing queues", - Labels: []string{"queue_name"}, - }) - if err != nil { - svc.Logger.Error("Failed to register queue size gauge", "error", err) + svc.Logger.Error("Failed to register demo counter", "error", err) os.Exit(1) } - err = svc.RegisterHistogram(service.MetricConfig{ - Name: "myapp_processing_duration_seconds", - Help: "Time spent processing requests", - Labels: []string{"operation"}, - // Using default buckets - }) - if err != nil { - svc.Logger.Error("Failed to register processing duration histogram", "error", err) - os.Exit(1) - } - - err = svc.RegisterSummary(service.MetricConfig{ - Name: "myapp_request_size_bytes", - Help: "Size of requests in bytes", - Labels: []string{"endpoint"}, - Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, - }) - if err != nil { - svc.Logger.Error("Failed to register request size summary", "error", err) - os.Exit(1) - } - - // Simulate background metrics collection - go func() { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - // Simulate changing active users - svc.Metrics.SetGauge("myapp_active_users", float64(rand.Intn(100)+50)) - - // Simulate queue sizes - svc.Metrics.SetGauge("myapp_queue_size", float64(rand.Intn(20)), "email") - svc.Metrics.SetGauge("myapp_queue_size", float64(rand.Intn(50)), "notifications") - svc.Metrics.SetGauge("myapp_queue_size", float64(rand.Intn(30)), "analytics") - - // Simulate some business events - eventTypes := []string{"user_signup", "purchase", "login", "logout"} - results := []string{"success", "failure"} - - for i := 0; i < rand.Intn(5); i++ { - eventType := eventTypes[rand.Intn(len(eventTypes))] - result := results[rand.Intn(len(results))] - svc.Metrics.IncCounter("myapp_business_events_total", eventType, result) - } - } - } - }() - - // Custom middleware to track request metrics - svc.Use(func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - - // Track request size - if r.ContentLength > 0 { - service.ObserveSummary(r, "myapp_request_size_bytes", float64(r.ContentLength), r.URL.Path) - } - - // Wrap response writer to capture status code - wrapper := &responseWriter{ResponseWriter: w, statusCode: 200} - - // Process request - next.ServeHTTP(wrapper, r) - - // Record metrics - duration := time.Since(start) - service.IncCounter(r, "myapp_requests_total", r.Method, r.URL.Path, strconv.Itoa(wrapper.statusCode)) - service.ObserveHistogram(r, "myapp_processing_duration_seconds", duration.Seconds(), "http_request") - }) - }) - // Main handler svc.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - logger.Info("Home page requested") - - w.Write([]byte("Prometheus Counter Demo Service\n")) - w.Write([]byte("Available endpoints:\n")) - w.Write([]byte(" - /api/users (GET, POST)\n")) - w.Write([]byte(" - /api/orders (GET, POST)\n")) - w.Write([]byte(" - /api/process (POST)\n")) - w.Write([]byte(" - /metrics (Prometheus metrics)\n")) + _, _ = w.Write([]byte("Hello, World!")) }) - // Users API endpoint - svc.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - - switch r.Method { - case "GET": - logger.Info("Fetching users") - // Simulate processing time - time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond) - - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"users": ["alice", "bob", "charlie"]}`)) - - case "POST": - logger.Info("Creating user") - // Simulate processing time - time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond) - - // Simulate success/failure - if rand.Float32() < 0.9 { - service.IncCounter(r, "myapp_business_events_total", "user_creation", "success") - w.WriteHeader(http.StatusCreated) - w.Write([]byte(`{"status": "created"}`)) - } else { - service.IncCounter(r, "myapp_business_events_total", "user_creation", "failure") - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(`{"error": "validation failed"}`)) - } - - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } + svc.HandleFunc("/demo", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("Demo counter incremented")) }) - // Orders API endpoint - svc.HandleFunc("/api/orders", func(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - - switch r.Method { - case "GET": - logger.Info("Fetching orders") - time.Sleep(time.Duration(rand.Intn(150)) * time.Millisecond) - - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"orders": [{"id": 1, "amount": 100}, {"id": 2, "amount": 200}]}`)) - - case "POST": - logger.Info("Creating order") - time.Sleep(time.Duration(rand.Intn(300)) * time.Millisecond) - - // Simulate success/failure - if rand.Float32() < 0.8 { - service.IncCounter(r, "myapp_business_events_total", "order_creation", "success") - w.WriteHeader(http.StatusCreated) - w.Write([]byte(`{"status": "created", "order_id": 123}`)) - } else { - service.IncCounter(r, "myapp_business_events_total", "order_creation", "failure") - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(`{"error": "insufficient funds"}`)) - } - - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } - }) - - // Heavy processing endpoint - svc.HandleFunc("/api/process", func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - - logger := service.GetLogger(r) - logger.Info("Processing heavy operation") - - // Simulate heavy processing - start := time.Now() - time.Sleep(time.Duration(rand.Intn(1000)+500) * time.Millisecond) - duration := time.Since(start) - - // Record processing duration - service.ObserveHistogram(r, "myapp_processing_duration_seconds", duration.Seconds(), "heavy_processing") - - // Simulate success/failure - if rand.Float32() < 0.7 { - service.IncCounter(r, "myapp_business_events_total", "heavy_processing", "success") - w.WriteHeader(http.StatusOK) - w.Write([]byte(fmt.Sprintf(`{"status": "completed", "duration": "%v"}`, duration))) - } else { - service.IncCounter(r, "myapp_business_events_total", "heavy_processing", "failure") - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error": "processing failed"}`)) - } - }) - - // Metrics summary endpoint - svc.HandleFunc("/metrics-summary", func(w http.ResponseWriter, r *http.Request) { - logger := service.GetLogger(r) - logger.Info("Metrics summary requested") - - w.Header().Set("Content-Type", "text/plain") - w.Write([]byte("Custom Metrics Summary\n")) - w.Write([]byte("======================\n\n")) - w.Write([]byte("Available custom metrics:\n")) - w.Write([]byte("- myapp_requests_total: Total HTTP requests by method, endpoint, and status\n")) - w.Write([]byte("- myapp_business_events_total: Business events by type and result\n")) - w.Write([]byte("- myapp_active_users: Current number of active users\n")) - w.Write([]byte("- myapp_queue_size: Size of processing queues\n")) - w.Write([]byte("- myapp_processing_duration_seconds: Request processing time\n")) - w.Write([]byte("- myapp_request_size_bytes: Size of incoming requests\n\n")) - w.Write([]byte("View all metrics at: http://localhost:9090/metrics\n")) - }) - - svc.Logger.Info("Starting Prometheus counter demo service...") svc.Logger.Info("Service available at http://localhost:8080") + svc.Logger.Info("Demo counter at http://localhost:8080/demo") svc.Logger.Info("Prometheus metrics at http://localhost:9090/metrics") - svc.Logger.Info("Metrics summary at http://localhost:8080/metrics-summary") - svc.Logger.Info("") - svc.Logger.Info("Try making requests to generate metrics:") - svc.Logger.Info(" curl http://localhost:8080/api/users") - svc.Logger.Info(" curl -X POST http://localhost:8080/api/users") - svc.Logger.Info(" curl -X POST http://localhost:8080/api/process") - svc.Logger.Info("") - svc.Logger.Info("Then check metrics at: http://localhost:9090/metrics") if err := svc.Start(); err != nil { svc.Logger.Error("Failed to start service", "error", err) os.Exit(1) } } - -// responseWriter wraps http.ResponseWriter to capture status code -type responseWriter struct { - http.ResponseWriter - statusCode int -} - -func (rw *responseWriter) WriteHeader(code int) { - rw.statusCode = code - rw.ResponseWriter.WriteHeader(code) -} diff --git a/metrics.go b/metrics.go index 76b22c9..b92008a 100644 --- a/metrics.go +++ b/metrics.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "sync" "time" @@ -86,28 +87,39 @@ func NewMetricsCollector(serviceName string) *MetricsCollector { return metricsCollector } +// ensureMetricNamePrefix ensures the metric name has the service name prefix +func (mc *MetricsCollector) ensureMetricNamePrefix(name string) string { + if !strings.HasPrefix(name, mc.serviceName+"_") { + return mc.serviceName + "_" + name + } + return name +} + // RegisterCounter registers a new counter metric func (mc *MetricsCollector) RegisterCounter(config MetricConfig) error { mc.mu.Lock() defer mc.mu.Unlock() - if _, exists := mc.counters[config.Name]; exists { - return fmt.Errorf("counter %s already exists", config.Name) //nolint:err113 + // Ensure metric name has service prefix + prefixedName := mc.ensureMetricNamePrefix(config.Name) + + if _, exists := mc.counters[prefixedName]; exists { + return fmt.Errorf("counter %s already exists", prefixedName) //nolint:err113 } counter := prometheus.NewCounterVec( prometheus.CounterOpts{ - Name: config.Name, + Name: prefixedName, Help: config.Help, }, config.Labels, ) if err := mc.registry.Register(counter); err != nil { - return fmt.Errorf("failed to register counter %s: %w", config.Name, err) + return fmt.Errorf("failed to register counter %s: %w", prefixedName, err) } - mc.counters[config.Name] = counter + mc.counters[prefixedName] = counter return nil } @@ -117,23 +129,26 @@ func (mc *MetricsCollector) RegisterGauge(config MetricConfig) error { mc.mu.Lock() defer mc.mu.Unlock() - if _, exists := mc.gauges[config.Name]; exists { - return fmt.Errorf("gauge %s already exists", config.Name) //nolint:err113 + // Ensure metric name has service prefix + prefixedName := mc.ensureMetricNamePrefix(config.Name) + + if _, exists := mc.gauges[prefixedName]; exists { + return fmt.Errorf("gauge %s already exists", prefixedName) //nolint:err113 } gauge := prometheus.NewGaugeVec( prometheus.GaugeOpts{ - Name: config.Name, + Name: prefixedName, Help: config.Help, }, config.Labels, ) if err := mc.registry.Register(gauge); err != nil { - return fmt.Errorf("failed to register gauge %s: %w", config.Name, err) + return fmt.Errorf("failed to register gauge %s: %w", prefixedName, err) } - mc.gauges[config.Name] = gauge + mc.gauges[prefixedName] = gauge return nil } @@ -143,8 +158,11 @@ func (mc *MetricsCollector) RegisterHistogram(config MetricConfig) error { mc.mu.Lock() defer mc.mu.Unlock() - if _, exists := mc.histograms[config.Name]; exists { - return fmt.Errorf("histogram %s already exists", config.Name) //nolint:err113 + // Ensure metric name has service prefix + prefixedName := mc.ensureMetricNamePrefix(config.Name) + + if _, exists := mc.histograms[prefixedName]; exists { + return fmt.Errorf("histogram %s already exists", prefixedName) //nolint:err113 } buckets := config.Buckets @@ -154,7 +172,7 @@ func (mc *MetricsCollector) RegisterHistogram(config MetricConfig) error { histogram := prometheus.NewHistogramVec( prometheus.HistogramOpts{ - Name: config.Name, + Name: prefixedName, Help: config.Help, Buckets: buckets, }, @@ -162,10 +180,10 @@ func (mc *MetricsCollector) RegisterHistogram(config MetricConfig) error { ) if err := mc.registry.Register(histogram); err != nil { - return fmt.Errorf("failed to register histogram %s: %w", config.Name, err) + return fmt.Errorf("failed to register histogram %s: %w", prefixedName, err) } - mc.histograms[config.Name] = histogram + mc.histograms[prefixedName] = histogram return nil } @@ -175,8 +193,11 @@ func (mc *MetricsCollector) RegisterSummary(config MetricConfig) error { mc.mu.Lock() defer mc.mu.Unlock() - if _, exists := mc.summaries[config.Name]; exists { - return fmt.Errorf("summary %s already exists", config.Name) //nolint:err113 + // Ensure metric name has service prefix + prefixedName := mc.ensureMetricNamePrefix(config.Name) + + if _, exists := mc.summaries[prefixedName]; exists { + return fmt.Errorf("summary %s already exists", prefixedName) //nolint:err113 } objectives := config.Objectives @@ -186,7 +207,7 @@ func (mc *MetricsCollector) RegisterSummary(config MetricConfig) error { summary := prometheus.NewSummaryVec( prometheus.SummaryOpts{ - Name: config.Name, + Name: prefixedName, Help: config.Help, Objectives: objectives, }, @@ -194,10 +215,10 @@ func (mc *MetricsCollector) RegisterSummary(config MetricConfig) error { ) if err := mc.registry.Register(summary); err != nil { - return fmt.Errorf("failed to register summary %s: %w", config.Name, err) + return fmt.Errorf("failed to register summary %s: %w", prefixedName, err) } - mc.summaries[config.Name] = summary + mc.summaries[prefixedName] = summary return nil } @@ -207,9 +228,12 @@ func (mc *MetricsCollector) IncCounter(name string, labels ...string) error { mc.mu.RLock() defer mc.mu.RUnlock() - counter, exists := mc.counters[name] + // Ensure metric name has service prefix + prefixedName := mc.ensureMetricNamePrefix(name) + + counter, exists := mc.counters[prefixedName] if !exists { - return fmt.Errorf("counter %s not found", name) //nolint:err113 + return fmt.Errorf("counter %s not found", prefixedName) //nolint:err113 } counter.WithLabelValues(labels...).Inc() @@ -222,9 +246,12 @@ func (mc *MetricsCollector) AddCounter(name string, value float64, labels ...str mc.mu.RLock() defer mc.mu.RUnlock() - counter, exists := mc.counters[name] + // Ensure metric name has service prefix + prefixedName := mc.ensureMetricNamePrefix(name) + + counter, exists := mc.counters[prefixedName] if !exists { - return fmt.Errorf("counter %s not found", name) //nolint:err113 + return fmt.Errorf("counter %s not found", prefixedName) //nolint:err113 } counter.WithLabelValues(labels...).Add(value) @@ -237,9 +264,12 @@ func (mc *MetricsCollector) SetGauge(name string, value float64, labels ...strin mc.mu.RLock() defer mc.mu.RUnlock() - gauge, exists := mc.gauges[name] + // Ensure metric name has service prefix + prefixedName := mc.ensureMetricNamePrefix(name) + + gauge, exists := mc.gauges[prefixedName] if !exists { - return fmt.Errorf("gauge %s not found", name) //nolint:err113 + return fmt.Errorf("gauge %s not found", prefixedName) //nolint:err113 } gauge.WithLabelValues(labels...).Set(value) @@ -252,9 +282,12 @@ func (mc *MetricsCollector) IncGauge(name string, labels ...string) error { mc.mu.RLock() defer mc.mu.RUnlock() - gauge, exists := mc.gauges[name] + // Ensure metric name has service prefix + prefixedName := mc.ensureMetricNamePrefix(name) + + gauge, exists := mc.gauges[prefixedName] if !exists { - return fmt.Errorf("gauge %s not found", name) //nolint:err113 + return fmt.Errorf("gauge %s not found", prefixedName) //nolint:err113 } gauge.WithLabelValues(labels...).Inc() @@ -267,9 +300,12 @@ func (mc *MetricsCollector) DecGauge(name string, labels ...string) error { mc.mu.RLock() defer mc.mu.RUnlock() - gauge, exists := mc.gauges[name] + // Ensure metric name has service prefix + prefixedName := mc.ensureMetricNamePrefix(name) + + gauge, exists := mc.gauges[prefixedName] if !exists { - return fmt.Errorf("gauge %s not found", name) //nolint:err113 + return fmt.Errorf("gauge %s not found", prefixedName) //nolint:err113 } gauge.WithLabelValues(labels...).Dec() @@ -282,9 +318,12 @@ func (mc *MetricsCollector) AddGauge(name string, value float64, labels ...strin mc.mu.RLock() defer mc.mu.RUnlock() - gauge, exists := mc.gauges[name] + // Ensure metric name has service prefix + prefixedName := mc.ensureMetricNamePrefix(name) + + gauge, exists := mc.gauges[prefixedName] if !exists { - return fmt.Errorf("gauge %s not found", name) //nolint:err113 + return fmt.Errorf("gauge %s not found", prefixedName) //nolint:err113 } gauge.WithLabelValues(labels...).Add(value) @@ -297,9 +336,12 @@ func (mc *MetricsCollector) ObserveHistogram(name string, value float64, labels mc.mu.RLock() defer mc.mu.RUnlock() - histogram, exists := mc.histograms[name] + // Ensure metric name has service prefix + prefixedName := mc.ensureMetricNamePrefix(name) + + histogram, exists := mc.histograms[prefixedName] if !exists { - return fmt.Errorf("histogram %s not found", name) //nolint:err113 + return fmt.Errorf("histogram %s not found", prefixedName) //nolint:err113 } histogram.WithLabelValues(labels...).Observe(value) @@ -312,9 +354,12 @@ func (mc *MetricsCollector) ObserveSummary(name string, value float64, labels .. mc.mu.RLock() defer mc.mu.RUnlock() - summary, exists := mc.summaries[name] + // Ensure metric name has service prefix + prefixedName := mc.ensureMetricNamePrefix(name) + + summary, exists := mc.summaries[prefixedName] if !exists { - return fmt.Errorf("summary %s not found", name) //nolint:err113 + return fmt.Errorf("summary %s not found", prefixedName) //nolint:err113 } summary.WithLabelValues(labels...).Observe(value) diff --git a/metrics_test.go b/metrics_test.go index b687671..7585b5e 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -77,7 +77,7 @@ func TestMetricsCollector_RegisterCounter(t *testing.T) { t.Fatalf("expected no error, got %v", err) } - if _, exists := metrics.counters["test_counter"]; !exists { + if _, exists := metrics.counters["test-service_test_counter"]; !exists { t.Error("expected counter to be registered") } }) @@ -123,7 +123,7 @@ func TestMetricsCollector_RegisterGauge(t *testing.T) { t.Fatalf("expected no error, got %v", err) } - if _, exists := metrics.gauges["test_gauge"]; !exists { + if _, exists := metrics.gauges["test-service_test_gauge"]; !exists { t.Error("expected gauge to be registered") } } @@ -147,7 +147,7 @@ func TestMetricsCollector_RegisterHistogram(t *testing.T) { t.Fatalf("expected no error, got %v", err) } - if _, exists := metrics.histograms["test_histogram"]; !exists { + if _, exists := metrics.histograms["test-service_test_histogram"]; !exists { t.Error("expected histogram to be registered") } }) @@ -167,7 +167,7 @@ func TestMetricsCollector_RegisterHistogram(t *testing.T) { t.Fatalf("expected no error, got %v", err) } - if _, exists := metrics.histograms["test_histogram_custom"]; !exists { + if _, exists := metrics.histograms["test-service_test_histogram_custom"]; !exists { t.Error("expected histogram to be registered") } }) @@ -192,7 +192,7 @@ func TestMetricsCollector_RegisterSummary(t *testing.T) { t.Fatalf("expected no error, got %v", err) } - if _, exists := metrics.summaries["test_summary"]; !exists { + if _, exists := metrics.summaries["test-service_test_summary"]; !exists { t.Error("expected summary to be registered") } }) @@ -212,7 +212,7 @@ func TestMetricsCollector_RegisterSummary(t *testing.T) { t.Fatalf("expected no error, got %v", err) } - if _, exists := metrics.summaries["test_summary_custom"]; !exists { + if _, exists := metrics.summaries["test-service_test_summary_custom"]; !exists { t.Error("expected summary to be registered") } }) @@ -244,7 +244,7 @@ func TestMetricsCollector_CounterOperations(t *testing.T) { } // Verify the counter was incremented - counter := metrics.counters["test_counter_ops"] + counter := metrics.counters["test-service_test_counter_ops"] metric := &dto.Metric{} err = counter.WithLabelValues("inc").Write(metric) @@ -266,7 +266,7 @@ func TestMetricsCollector_CounterOperations(t *testing.T) { } // Verify the counter was incremented by 5.5 - counter := metrics.counters["test_counter_ops"] + counter := metrics.counters["test-service_test_counter_ops"] metric := &dto.Metric{} err = counter.WithLabelValues("add").Write(metric) @@ -320,7 +320,7 @@ func TestMetricsCollector_GaugeOperations(t *testing.T) { } // Verify the gauge value - gauge := metrics.gauges["test_gauge_ops"] + gauge := metrics.gauges["test-service_test_gauge_ops"] metric := &dto.Metric{} err = gauge.WithLabelValues("set").Write(metric) @@ -348,7 +348,7 @@ func TestMetricsCollector_GaugeOperations(t *testing.T) { } // Verify the gauge was incremented - gauge := metrics.gauges["test_gauge_ops"] + gauge := metrics.gauges["test-service_test_gauge_ops"] metric := &dto.Metric{} err = gauge.WithLabelValues("inc").Write(metric) @@ -376,7 +376,7 @@ func TestMetricsCollector_GaugeOperations(t *testing.T) { } // Verify the gauge was decremented - gauge := metrics.gauges["test_gauge_ops"] + gauge := metrics.gauges["test-service_test_gauge_ops"] metric := &dto.Metric{} err = gauge.WithLabelValues("dec").Write(metric) @@ -404,7 +404,7 @@ func TestMetricsCollector_GaugeOperations(t *testing.T) { } // Verify the gauge value - gauge := metrics.gauges["test_gauge_ops"] + gauge := metrics.gauges["test-service_test_gauge_ops"] metric := &dto.Metric{} err = gauge.WithLabelValues("add").Write(metric) @@ -457,7 +457,7 @@ func TestMetricsCollector_HistogramOperations(t *testing.T) { found := false for _, mf := range metricFamilies { - if mf.GetName() == "test_histogram_ops" { + if mf.GetName() == "test-service_test_histogram_ops" { found = true for _, metric := range mf.GetMetric() { @@ -513,7 +513,7 @@ func TestMetricsCollector_SummaryOperations(t *testing.T) { found := false for _, mf := range metricFamilies { - if mf.GetName() == "test_summary_ops" { + if mf.GetName() == "test-service_test_summary_ops" { found = true for _, metric := range mf.GetMetric() { @@ -716,7 +716,7 @@ func TestService_RegisterMetrics(t *testing.T) { t.Fatalf("expected no error, got %v", err) } - if _, exists := svc.Metrics.counters["service_test_counter"]; !exists { + if _, exists := svc.Metrics.counters["test-service_service_test_counter"]; !exists { t.Error("expected counter to be registered") } }) @@ -733,7 +733,7 @@ func TestService_RegisterMetrics(t *testing.T) { t.Fatalf("expected no error, got %v", err) } - if _, exists := svc.Metrics.gauges["service_test_gauge"]; !exists { + if _, exists := svc.Metrics.gauges["test-service_service_test_gauge"]; !exists { t.Error("expected gauge to be registered") } }) @@ -750,7 +750,7 @@ func TestService_RegisterMetrics(t *testing.T) { t.Fatalf("expected no error, got %v", err) } - if _, exists := svc.Metrics.histograms["service_test_histogram"]; !exists { + if _, exists := svc.Metrics.histograms["test-service_service_test_histogram"]; !exists { t.Error("expected histogram to be registered") } }) @@ -767,7 +767,7 @@ func TestService_RegisterMetrics(t *testing.T) { t.Fatalf("expected no error, got %v", err) } - if _, exists := svc.Metrics.summaries["service_test_summary"]; !exists { + if _, exists := svc.Metrics.summaries["test-service_service_test_summary"]; !exists { t.Error("expected summary to be registered") } }) From 6e559525740732fe7e7080fefe3fab7b0ede07fd Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Sat, 12 Jul 2025 02:43:04 +0200 Subject: [PATCH 15/17] chore: update go.mod and refactor ensureMetricNamePrefix function - Added `github.com/prometheus/client_model` as a direct dependency in `go.mod`. - Moved the `ensureMetricNamePrefix` function to a new location in `metrics.go` for better organization and clarity. --- go.mod | 2 +- metrics.go | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 0903c5a..c660058 100644 --- a/go.mod +++ b/go.mod @@ -6,13 +6,13 @@ require ( github.com/caarlos0/env/v11 v11.3.1 github.com/hellofresh/health-go/v5 v5.5.5 github.com/prometheus/client_golang v1.22.0 + github.com/prometheus/client_model v0.6.1 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - 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 go.opentelemetry.io/otel v1.35.0 // indirect diff --git a/metrics.go b/metrics.go index b92008a..06e99f2 100644 --- a/metrics.go +++ b/metrics.go @@ -87,14 +87,6 @@ func NewMetricsCollector(serviceName string) *MetricsCollector { return metricsCollector } -// ensureMetricNamePrefix ensures the metric name has the service name prefix -func (mc *MetricsCollector) ensureMetricNamePrefix(name string) string { - if !strings.HasPrefix(name, mc.serviceName+"_") { - return mc.serviceName + "_" + name - } - return name -} - // RegisterCounter registers a new counter metric func (mc *MetricsCollector) RegisterCounter(config MetricConfig) error { mc.mu.Lock() @@ -372,6 +364,15 @@ func (mc *MetricsCollector) GetRegistry() *prometheus.Registry { return mc.registry } +// ensureMetricNamePrefix ensures the metric name has the service name prefix +func (mc *MetricsCollector) ensureMetricNamePrefix(name string) string { + if !strings.HasPrefix(name, mc.serviceName+"_") { + return mc.serviceName + "_" + name + } + + return name +} + // responseWriter wraps http.ResponseWriter to capture status code type responseWriter struct { http.ResponseWriter From 02f0d981d21e65794dfad742a5a42e9d83cc1ee9 Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Sat, 12 Jul 2025 02:44:22 +0200 Subject: [PATCH 16/17] chore: update golangci-lint action version - Upgraded the golangci-lint GitHub Action from version v3 to v8 in the CI workflow configuration to ensure compatibility with the latest linting features and improvements. --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5701467..cfd8986 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,6 +25,6 @@ jobs: go-version: "stable" - name: golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v8 with: version: latest From 27c845bddc81622a7648d42382d7dbab9d03eea1 Mon Sep 17 00:00:00 2001 From: MarvinJWendt Date: Sat, 12 Jul 2025 02:49:28 +0200 Subject: [PATCH 17/17] fix: update health check comment in README to reflect PostgreSQL --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 21c26d8..6efd8ea 100644 --- a/README.md +++ b/README.md @@ -365,7 +365,7 @@ import ( func main() { svc := service.New("my-service", nil) - // MySQL health check + // PostgreSQL health check svc.RegisterHealthCheck(health.Config{ Name: "postgresql", Timeout: time.Second * 5,