Merged in feature/prometheus (pull request #52)
API instrumentation - metrics and logging * api instrumentation prometheus metrics and logging for all routes * Merge branch 'main' of bitbucket.org:aarete/query-orchestration into feature/prometheus * add log_level and sync the config initialize * docs * cleanup * add to compose:up:test * docs for prometheus testing * custom metrics * cleanup * merge main * add tests * cleanup docs * cleanup * lint * fix unit tests (attempt) * fix tests * resolvesetlogger * expose 8080 * compose changes * bugfixesformichael * don't build _test * merge in main * job_sync_runner
This commit is contained in:
@@ -8,8 +8,10 @@ import (
|
||||
"net/http"
|
||||
"queryorchestration/internal/server"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/labstack/echo-contrib/echoprometheus"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
_ "github.com/lib/pq"
|
||||
@@ -59,6 +61,33 @@ type Server struct {
|
||||
cleanup func() error
|
||||
}
|
||||
|
||||
func getSlogCustomEchoMiddleware(logger *slog.Logger) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
start := time.Now()
|
||||
|
||||
err := next(c)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
}
|
||||
|
||||
req := c.Request()
|
||||
res := c.Response()
|
||||
|
||||
logger.Info("HTTP request",
|
||||
"method", req.Method,
|
||||
"uri", req.RequestURI,
|
||||
"status", res.Status,
|
||||
"latency", time.Since(start).String(),
|
||||
"error", err,
|
||||
"remote_ip", c.RealIP(),
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func New(ctx context.Context, cfg Config) (*Server, error) {
|
||||
|
||||
cleanup, err := server.New(ctx, cfg)
|
||||
@@ -68,7 +97,12 @@ func New(ctx context.Context, cfg Config) (*Server, error) {
|
||||
|
||||
e := echo.New()
|
||||
|
||||
e.Use(middleware.Logger())
|
||||
// Add Prometheus middleware
|
||||
e.Use(echoprometheus.NewMiddleware("echo")) // Add the middleware
|
||||
e.GET("/metrics", echoprometheus.NewHandler())
|
||||
|
||||
// Other middlewares
|
||||
e.Use(getSlogCustomEchoMiddleware(cfg.GetLogger()))
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.CORS())
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"queryorchestration/internal/serviceconfig/queue"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
"github.com/joho/godotenv"
|
||||
@@ -90,13 +91,38 @@ func getBaseConfig(cfg ConfigProvider) *BaseConfig {
|
||||
return baseConfig
|
||||
}
|
||||
|
||||
var (
|
||||
initOnce sync.Once
|
||||
initErr error
|
||||
)
|
||||
|
||||
func ResetConfigInitOnceTestOnly() {
|
||||
initOnce = sync.Once{}
|
||||
initErr = nil
|
||||
}
|
||||
|
||||
// InitializeConfig will initialize all of the configuration for the serivice
|
||||
// and is guaranteed to run only once however many times it gets called.
|
||||
func InitializeConfig(cfg ConfigProvider) error {
|
||||
var initErr error
|
||||
initOnce.Do(func() {
|
||||
initErr = initializeConfigPrivate(cfg)
|
||||
})
|
||||
|
||||
return initErr
|
||||
}
|
||||
|
||||
// initializeConfigPrivate is the internal implementation of InitializeConfig so that
|
||||
// testing can call it multiple times.
|
||||
func initializeConfigPrivate(cfg ConfigProvider) error {
|
||||
baseConfig := getBaseConfig(cfg)
|
||||
if baseConfig == nil {
|
||||
return errors.New("no BaseConfig found in the provided config struct")
|
||||
initErr = errors.New("no BaseConfig found in the provided config struct")
|
||||
return initErr
|
||||
}
|
||||
|
||||
baseConfig.SetDefaultLogger()
|
||||
// Initialize with info level
|
||||
cfg.SetDefaultLogger()
|
||||
|
||||
if err := godotenv.Load(); err != nil {
|
||||
slog.Warn("No .env file found or error loading it", "error", err)
|
||||
@@ -111,13 +137,14 @@ func InitializeConfig(cfg ConfigProvider) error {
|
||||
} else {
|
||||
slog.Error("Error parsing environment variables", "error", err)
|
||||
}
|
||||
|
||||
return err
|
||||
initErr = err
|
||||
return initErr
|
||||
}
|
||||
|
||||
baseConfig.SetDefaultLogger()
|
||||
// Reinitialize with environment level
|
||||
cfg.SetDefaultLogger()
|
||||
|
||||
return nil
|
||||
return initErr
|
||||
}
|
||||
|
||||
func (b *BaseConfig) GetBasePath() string {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package serviceconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"os"
|
||||
"queryorchestration/internal/serviceconfig/aws"
|
||||
"queryorchestration/internal/serviceconfig/database"
|
||||
@@ -12,7 +10,8 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type TestingServiceNameConfig struct {
|
||||
// Struct specifically for initialize config testing
|
||||
type initializeConfigTestStruct struct {
|
||||
BaseConfig
|
||||
AppEnv string `env:"APP_ENV"`
|
||||
BoolTest bool `env:"BOOL_TEST"`
|
||||
@@ -93,14 +92,17 @@ func TestInitializeConfig(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Clear environment at the start of each subtest
|
||||
os.Clearenv()
|
||||
|
||||
// Set environment variables for this test case
|
||||
for k, v := range tt.envVars {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
|
||||
cfg := &TestingServiceNameConfig{}
|
||||
err := InitializeConfig(cfg)
|
||||
// Use the test-specific config struct
|
||||
cfg := &initializeConfigTestStruct{}
|
||||
err := initializeConfigPrivate(cfg)
|
||||
|
||||
logger := cfg.GetLogger()
|
||||
assert.NotNil(t, logger)
|
||||
@@ -111,11 +113,10 @@ func TestInitializeConfig(t *testing.T) {
|
||||
t.Errorf("InitializeConfig() error = nil, wantErr %v", tt.wantErr)
|
||||
return
|
||||
}
|
||||
// if we expect an error, check if the error message tt.errMessageContains is contained in the error message
|
||||
// Check error message contains expected string
|
||||
if tt.errMessageContains != "" && !strings.Contains(err.Error(), tt.errMessageContains) {
|
||||
t.Errorf("InitializeConfig() error = %v, want error containing %v", err, tt.errMessageContains)
|
||||
}
|
||||
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("InitializeConfig() unexpected error = %v", err)
|
||||
@@ -136,12 +137,15 @@ func TestInitializeConfig(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
os.Clearenv()
|
||||
|
||||
}
|
||||
|
||||
func TestGetBaseConfig(t *testing.T) {
|
||||
assert.NotNil(t, getBaseConfig(&BaseConfig{}))
|
||||
assert.Nil(t, getBaseConfig(nil))
|
||||
assert.NotNil(t, getBaseConfig(&TestingServiceNameConfig{}))
|
||||
assert.NotNil(t, getBaseConfig(&initializeConfigTestStruct{}))
|
||||
os.Clearenv()
|
||||
}
|
||||
|
||||
func TestGetBasePath(t *testing.T) {
|
||||
@@ -176,62 +180,3 @@ func TestSetAWSConfig(t *testing.T) {
|
||||
cfg.SetAWSConfig(&acfg)
|
||||
assert.Equal(t, acfg, cfg.AWSConfig)
|
||||
}
|
||||
|
||||
func TestPrintConfigRecursive(t *testing.T) {
|
||||
envVars := map[string]string{
|
||||
"DB_USER": "postgres",
|
||||
"DB_PASS": "password",
|
||||
"DB_HOST": "localhost",
|
||||
"DB_PORT": "5432",
|
||||
"DB_NAME": "query_orchestration",
|
||||
"DB_NOSSL": "true",
|
||||
"AWS_ACCESS_KEY_ID": "key",
|
||||
"AWS_SECRET_ACCESS_KEY": "password",
|
||||
"AWS_REGION": "region",
|
||||
"PWD": "/foo",
|
||||
}
|
||||
|
||||
// Set environment variables
|
||||
for k, v := range envVars {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
|
||||
// Create a buffer to capture log output
|
||||
var logBuffer bytes.Buffer
|
||||
|
||||
// Create a test logger that writes to our buffer
|
||||
testLogger := slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
cfg := &BaseConfig{}
|
||||
err := InitializeConfig(cfg)
|
||||
assert.NoError(t, err)
|
||||
|
||||
cfg.Logger = testLogger
|
||||
|
||||
// Call the function we're testing
|
||||
cfg.PrintConfig("secret")
|
||||
|
||||
// Get the log output as a string
|
||||
logOutput := logBuffer.String()
|
||||
|
||||
// Check that each environment variable value appears in the logs
|
||||
// except for DB_PASS which should be masked
|
||||
for envKey, envValue := range envVars {
|
||||
if envKey == "DB_PASS" || envKey == "AWS_SECRET_ACCESS_KEY" {
|
||||
// Password should be masked
|
||||
if !strings.Contains(logOutput, "pas") {
|
||||
t.Errorf("Expected masked password in logs, got: %s", logOutput)
|
||||
}
|
||||
// Full password should not appear
|
||||
if strings.Contains(logOutput, envValue) {
|
||||
t.Errorf("Full password should not appear in logs: %s", logOutput)
|
||||
}
|
||||
} else {
|
||||
if !strings.Contains(logOutput, envValue) {
|
||||
t.Errorf("Expected %s in logs, but it was not found. Log output: %s", envValue, logOutput)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package serviceconfig_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"os"
|
||||
"queryorchestration/internal/serviceconfig"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPrintConfigRecursive tests the PrintConfigRecursive function as a blackbox test.
|
||||
func TestPrintConfigRecursive(t *testing.T) {
|
||||
serviceconfig.ResetConfigInitOnceTestOnly()
|
||||
os.Clearenv()
|
||||
envVars := map[string]string{
|
||||
"DB_USER": "postgres",
|
||||
"DB_PASS": "password",
|
||||
"DB_HOST": "localhost",
|
||||
"DB_PORT": "5432",
|
||||
"DB_NAME": "query_orchestration",
|
||||
"DB_NOSSL": "true",
|
||||
"AWS_ACCESS_KEY_ID": "key",
|
||||
"AWS_SECRET_ACCESS_KEY": "password",
|
||||
"AWS_REGION": "region",
|
||||
"PWD": "/foo", // Only include vars needed by BaseConfig
|
||||
}
|
||||
|
||||
// Set environment variables
|
||||
for k, v := range envVars {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
|
||||
// Create a buffer to capture log output
|
||||
var logBuffer bytes.Buffer
|
||||
|
||||
// Create a test logger that writes to our buffer
|
||||
testLogger := slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
}))
|
||||
|
||||
// Use BaseConfig instead of initializeConfigTestStruct
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
err := serviceconfig.InitializeConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Error initializing config: %v", err)
|
||||
}
|
||||
|
||||
// Set logger after initialization
|
||||
cfg.Logger = testLogger
|
||||
|
||||
// Call the function we're testing
|
||||
cfg.PrintConfig("secret")
|
||||
|
||||
// Get the log output as a string
|
||||
logOutput := logBuffer.String()
|
||||
|
||||
// Check that each environment variable value appears in the logs
|
||||
// except for sensitive values which should be masked
|
||||
for envKey, envValue := range envVars {
|
||||
if envKey == "DB_PASS" || envKey == "AWS_SECRET_ACCESS_KEY" {
|
||||
// Password should be masked
|
||||
if strings.Contains(logOutput, envValue) {
|
||||
t.Errorf("Full password should not appear in logs: %s", logOutput)
|
||||
}
|
||||
// Should see at least part of the masked value
|
||||
if !strings.Contains(logOutput, "...") {
|
||||
t.Errorf("Expected masked password in logs, got: %s", logOutput)
|
||||
}
|
||||
} else if !strings.Contains(logOutput, envValue) {
|
||||
t.Errorf("Expected %s in logs, but it was not found. Log output: %s", envValue, logOutput)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
type LogConfig struct {
|
||||
Logger *slog.Logger
|
||||
LogLevel string `env:"LOG_LEVEL"`
|
||||
LogLevel string `env:"LOG_LEVEL" envDefault:"INFO"`
|
||||
}
|
||||
|
||||
type ConfigProvider interface {
|
||||
@@ -43,4 +43,6 @@ func (b *LogConfig) SetDefaultLogger() {
|
||||
}))
|
||||
|
||||
slog.SetDefault(b.Logger)
|
||||
|
||||
slog.Info("Logger initialized", "level", b.LogLevel)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Logger info
|
||||
To set the log level of the default logger that will be used
|
||||
throughout all of the services set the
|
||||
```bash
|
||||
LOG_LEVEL= DEBUG | INFO | WARN | ERROR
|
||||
|
||||
```
|
||||
and if not specified, `INFO` is the default.
|
||||
@@ -0,0 +1,183 @@
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// MetricType represents the type of Prometheus metric
|
||||
type MetricType string
|
||||
|
||||
const (
|
||||
Counter MetricType = "counter"
|
||||
Gauge MetricType = "gauge"
|
||||
Histogram MetricType = "histogram"
|
||||
Summary MetricType = "summary"
|
||||
)
|
||||
|
||||
// MetricDefinition defines the structure for creating a new metric
|
||||
type MetricDefinition struct {
|
||||
Type MetricType
|
||||
Name string
|
||||
Help string
|
||||
Labels []string
|
||||
Buckets []float64
|
||||
|
||||
// Objectives is needed for Prometheus Summary metrics to define quantile calculations
|
||||
Objectives map[float64]float64
|
||||
}
|
||||
|
||||
// Metrics represents the wrapper around Prometheus metrics
|
||||
type Metrics struct {
|
||||
metrics map[string]interface{}
|
||||
mu sync.RWMutex
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
// MetricsConfig represents the configuration for the metrics wrapper
|
||||
type MetricsConfig struct {
|
||||
Definitions []MetricDefinition
|
||||
Namespace string
|
||||
Subsystem string
|
||||
Port int
|
||||
ServeHttp bool
|
||||
}
|
||||
|
||||
// New creates a new Metrics instance
|
||||
func New(ctx context.Context, config MetricsConfig) (*Metrics, error) {
|
||||
m := &Metrics{
|
||||
metrics: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
for _, def := range config.Definitions {
|
||||
var metric interface{}
|
||||
var err error
|
||||
|
||||
switch def.Type {
|
||||
case Counter:
|
||||
metric = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Namespace: config.Namespace,
|
||||
Subsystem: config.Subsystem,
|
||||
Name: def.Name,
|
||||
Help: def.Help,
|
||||
},
|
||||
def.Labels,
|
||||
)
|
||||
case Gauge:
|
||||
metric = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: config.Namespace,
|
||||
Subsystem: config.Subsystem,
|
||||
Name: def.Name,
|
||||
Help: def.Help,
|
||||
},
|
||||
def.Labels,
|
||||
)
|
||||
case Histogram:
|
||||
opts := prometheus.HistogramOpts{
|
||||
Namespace: config.Namespace,
|
||||
Subsystem: config.Subsystem,
|
||||
Name: def.Name,
|
||||
Help: def.Help,
|
||||
}
|
||||
if len(def.Buckets) > 0 {
|
||||
opts.Buckets = def.Buckets
|
||||
}
|
||||
metric = prometheus.NewHistogramVec(opts, def.Labels)
|
||||
case Summary:
|
||||
opts := prometheus.SummaryOpts{
|
||||
Namespace: config.Namespace,
|
||||
Subsystem: config.Subsystem,
|
||||
Name: def.Name,
|
||||
Help: def.Help,
|
||||
}
|
||||
if def.Objectives != nil {
|
||||
opts.Objectives = def.Objectives
|
||||
}
|
||||
metric = prometheus.NewSummaryVec(opts, def.Labels)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported metric type: %s", def.Type)
|
||||
}
|
||||
|
||||
collector, ok := metric.(prometheus.Collector)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("metric %s is not a valid prometheus collector", def.Name)
|
||||
}
|
||||
if err = prometheus.Register(collector); err != nil {
|
||||
return nil, fmt.Errorf("failed to register metric %s: %w", def.Name, err)
|
||||
}
|
||||
|
||||
m.metrics[def.Name] = metric
|
||||
}
|
||||
|
||||
if config.ServeHttp {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
m.server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", config.Port),
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := m.server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
fmt.Printf("Metrics server error: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Listen for context cancellation
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
if err := m.server.Shutdown(context.Background()); err != nil {
|
||||
fmt.Printf("Error shutting down metrics server: %v\n", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the metrics server
|
||||
func (m *Metrics) Shutdown(ctx context.Context) error {
|
||||
if m.server != nil {
|
||||
return m.server.Shutdown(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordMetric records a value for a given metric
|
||||
func (m *Metrics) RecordMetric(name string, value float64, labels map[string]string) error {
|
||||
m.mu.RLock()
|
||||
metric, exists := m.metrics[name]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("metric %s not found", name)
|
||||
}
|
||||
|
||||
labelValues := make([]string, 0, len(labels))
|
||||
for _, v := range labels {
|
||||
labelValues = append(labelValues, v)
|
||||
}
|
||||
|
||||
switch m := metric.(type) {
|
||||
case *prometheus.CounterVec:
|
||||
m.WithLabelValues(labelValues...).Add(value)
|
||||
case *prometheus.GaugeVec:
|
||||
m.WithLabelValues(labelValues...).Set(value)
|
||||
case *prometheus.HistogramVec:
|
||||
m.WithLabelValues(labelValues...).Observe(value)
|
||||
case *prometheus.SummaryVec:
|
||||
m.WithLabelValues(labelValues...).Observe(value)
|
||||
default:
|
||||
return fmt.Errorf("unknown metric type for %s", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Prometheus docs
|
||||
|
||||
All instructions assume you are already in the `devbox shell`
|
||||
|
||||
To run various services and inspect prometheus metrics first run the
|
||||
`task compose:up:test` to start the db and prometheus services.
|
||||
|
||||
Then you can run your service that exposed the metrics.
|
||||
|
||||
## Testing the API service
|
||||
Start the API server on the host like
|
||||
`DB_PORT=5430 go run ./cmd/queryService`
|
||||
|
||||
Once started you will be able to see the raw metrics at
|
||||
http://localhost:8080/metrics
|
||||
|
||||
The prometheus console will be running at
|
||||
http://localhost:9090/
|
||||
|
||||
The docs to the api server will be available at
|
||||
http://localhost:8080/swagger/index.html
|
||||
|
||||
For example to run the query endpoint (to see query related metrics generated) go to
|
||||
http://localhost:8080/query
|
||||
|
||||
## Sample prometheus queries for the console
|
||||
Duration of the /query api call (from Echo metrics)
|
||||
`echo_request_duration_seconds_sum{code="200", host="localhost:8080", instance="host.docker.internal:8080", job="api", method="GET", url="/query"}`
|
||||
|
||||
Memory allocation rate (from Go metrics)
|
||||
`rate(go_memstats_alloc_bytes_total[5m])`
|
||||
|
||||
Time since last GC
|
||||
`time() - go_memstats_last_gc_time_seconds`
|
||||
|
||||
Gc overhead
|
||||
`rate(go_gc_duration_seconds_sum[5m])`
|
||||
|
||||
|
||||
More examples TBD.
|
||||
@@ -0,0 +1,208 @@
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMetricsIntegration(t *testing.T) {
|
||||
// Create a context with cancellation for the test
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Step 1: Define our test metrics configuration
|
||||
config := MetricsConfig{
|
||||
Namespace: "test",
|
||||
Subsystem: "metrics",
|
||||
Port: 9090, // Using 9090 for tests
|
||||
ServeHttp: true, // Enable the built-in HTTP server
|
||||
Definitions: []MetricDefinition{
|
||||
{
|
||||
// Counter example: counting total events
|
||||
Type: Counter,
|
||||
Name: "events_total",
|
||||
Help: "Total number of events processed",
|
||||
Labels: []string{"type", "status"},
|
||||
},
|
||||
{
|
||||
// Gauge example: tracking current value
|
||||
Type: Gauge,
|
||||
Name: "queue_size",
|
||||
Help: "Current number of items in queue",
|
||||
Labels: []string{"queue_name"},
|
||||
},
|
||||
{
|
||||
// Histogram example: measuring distribution of values
|
||||
Type: Histogram,
|
||||
Name: "response_time_seconds",
|
||||
Help: "Response time distribution",
|
||||
Labels: []string{"endpoint"},
|
||||
Buckets: []float64{0.1, 0.5, 1, 2, 5}, // Define bucket boundaries
|
||||
},
|
||||
{
|
||||
// Summary example: calculating quantiles
|
||||
Type: Summary,
|
||||
Name: "request_size_bytes",
|
||||
Help: "Request size distribution",
|
||||
Labels: []string{"method"},
|
||||
Objectives: map[float64]float64{
|
||||
0.5: 0.05, // 50th percentile with 5% error
|
||||
0.9: 0.01, // 90th percentile with 1% error
|
||||
0.99: 0.001, // 99th percentile with 0.1% error
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Step 2: Create new metrics instance with context
|
||||
metrics, err := New(ctx, config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create metrics: %v", err)
|
||||
}
|
||||
|
||||
// Ensure cleanup after test
|
||||
defer func() {
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := metrics.Shutdown(shutdownCtx); err != nil {
|
||||
t.Errorf("Failed to shutdown metrics server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Step 3: Record some sample metrics
|
||||
// Record a counter metric
|
||||
err = metrics.RecordMetric("events_total", 42, map[string]string{
|
||||
"type": "user_login",
|
||||
"status": "success",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to record counter metric: %v", err)
|
||||
}
|
||||
|
||||
// Record a gauge metric
|
||||
err = metrics.RecordMetric("queue_size", 15, map[string]string{
|
||||
"queue_name": "default",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to record gauge metric: %v", err)
|
||||
}
|
||||
|
||||
// Record a histogram metric
|
||||
err = metrics.RecordMetric("response_time_seconds", 0.7, map[string]string{
|
||||
"endpoint": "/api/users",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to record histogram metric: %v", err)
|
||||
}
|
||||
|
||||
// Record a summary metric
|
||||
err = metrics.RecordMetric("request_size_bytes", 1024, map[string]string{
|
||||
"method": "POST",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to record summary metric: %v", err)
|
||||
}
|
||||
|
||||
// Step 4: Wait for HTTP server to start
|
||||
// Give the server a moment to start up
|
||||
time.Sleep(time.Second)
|
||||
|
||||
// Step 5: Fetch metrics from the HTTP endpoint
|
||||
resp, err := http.Get("http://localhost:9090/metrics")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch metrics: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
metricsOutput := string(body)
|
||||
// if needed for debugging.
|
||||
//log.Infof("Metrics output: %s", metricsOutput)
|
||||
|
||||
// Step 6: Verify that our custom metrics and go metrics are present
|
||||
expectedMetrics := []string{
|
||||
// Our custom metrics (with namespace and subsystem)
|
||||
"test_metrics_events_total",
|
||||
"test_metrics_queue_size",
|
||||
"test_metrics_response_time_seconds",
|
||||
"test_metrics_request_size_bytes",
|
||||
// Standard Go metrics that should be present
|
||||
"go_memstats_alloc_bytes_total",
|
||||
}
|
||||
|
||||
// Check for each expected metric
|
||||
for _, metric := range expectedMetrics {
|
||||
if !strings.Contains(metricsOutput, metric) {
|
||||
t.Errorf("Expected metric %s not found in output", metric)
|
||||
// Print a snippet of the output around where we expect to find the metric
|
||||
fmt.Printf("Searching for: %s\nOutput snippet: %s\n",
|
||||
metric,
|
||||
getOutputSnippet(metricsOutput, metric))
|
||||
}
|
||||
}
|
||||
|
||||
// Test cancellation
|
||||
t.Run("Context cancellation", func(t *testing.T) {
|
||||
// Create a new metrics instance with a separate context
|
||||
cancelCtx, cancelFunc := context.WithCancel(context.Background())
|
||||
_, err := New(cancelCtx, MetricsConfig{
|
||||
Namespace: "test_cancel",
|
||||
Port: 9091,
|
||||
ServeHttp: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create metrics for cancellation test: %v", err)
|
||||
}
|
||||
|
||||
// Wait for server to start
|
||||
time.Sleep(time.Second)
|
||||
|
||||
// Cancel the context
|
||||
cancelFunc()
|
||||
|
||||
// Try to connect to the server - it should fail after a short delay
|
||||
time.Sleep(time.Second)
|
||||
_, err = http.Get("http://localhost:9091/metrics")
|
||||
if err == nil {
|
||||
t.Error("Expected server to be shutdown after context cancellation")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to get a snippet of the output around where we expect to find the metric
|
||||
func getOutputSnippet(output string, searchTerm string) string {
|
||||
index := strings.Index(output, searchTerm)
|
||||
if index == -1 {
|
||||
return "Metric not found in output"
|
||||
}
|
||||
|
||||
// Get up to 200 characters before and after the search term
|
||||
start := max(0, index-200)
|
||||
end := min(len(output), index+200)
|
||||
return output[start:end]
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
# Prometheus (Doczy)
|
||||
|
||||
Prometheus is a simplified wrapper around the Prometheus client library for Go, making it easier to define and use Prometheus metrics in your services. It provides a straightforward way to create and record various types of Prometheus metrics with minimal boilerplate code.
|
||||
|
||||
## Features
|
||||
|
||||
- Simple configuration-based metric definition
|
||||
- Support for all major Prometheus metric types:
|
||||
- Counter
|
||||
- Gauge
|
||||
- Histogram
|
||||
- Summary
|
||||
- Automatic HTTP metrics endpoint setup
|
||||
- Thread-safe metric recording
|
||||
- Label support for all metric types
|
||||
- Context-based graceful shutdown
|
||||
- Clean server lifecycle management
|
||||
|
||||
## Example cmd (metricsExample_test)
|
||||
See `cmd/metricsExample_test` for a sample usage in a working main.go.
|
||||
The Readme.md in that directory has more details on how to use the harness.
|
||||
|
||||
## Running tests with prometheus server
|
||||
See [prometheus.md](./prometheus.md) for prometheus related instructions and testing instructions.
|
||||
|
||||
## Quick Start (with your own http server)
|
||||
This example (and the `metricsExample_test`) shows how to use the harness to create a simple http server that exposes metrics.
|
||||
If you are using this with an API server you can indicate in your config that you already have a http server and it will not start a http server for you.
|
||||
(e.g. Use the one that we automatically setup as Echo middleware)
|
||||
For a more complete and up-to-date example always refer to the `metricsExample_test` sample which you can run.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
"queryorchestration/internal/serviceconfig/prometheus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create a context that we can cancel
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
config := prometrics.Config{
|
||||
Namespace: "myapp",
|
||||
Subsystem: "subsystem",
|
||||
ServeHttp: true,
|
||||
Port: 8080,
|
||||
Definitions: []prometheus.MetricDefinition{
|
||||
{
|
||||
Type: prometheus.Counter,
|
||||
Name: "requests_total",
|
||||
Help: "Total number of requests processed",
|
||||
Labels: []string{"endpoint", "status"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
metrics, err := prometheus.New(ctx, config)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Setup graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigChan
|
||||
// Cancel context
|
||||
cancel()
|
||||
|
||||
// Give the server up to 5 seconds to shutdown gracefully
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := metrics.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("Error shutting down metrics server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Record a metric
|
||||
metrics.RecordMetric("requests_total", 1, map[string]string{
|
||||
"endpoint": "/api/v1",
|
||||
"status": "success",
|
||||
})
|
||||
|
||||
// Block until signal is received
|
||||
<-ctx.Done()
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### MetricsConfig Structure
|
||||
|
||||
```go
|
||||
type MetricsConfig struct {
|
||||
Definitions []MetricDefinition // List of metrics to create
|
||||
Namespace string // Namespace for all metrics
|
||||
Subsystem string // Subsystem for all metrics
|
||||
ServeHttp bool // Whether to serve metrics endpoint
|
||||
Port int // Port for metrics endpoint
|
||||
}
|
||||
```
|
||||
|
||||
### MetricDefinition Structure
|
||||
|
||||
```go
|
||||
type MetricDefinition struct {
|
||||
Type MetricType // Counter, Gauge, Histogram, or Summary
|
||||
Name string // Metric name
|
||||
Help string // Help text for the metric
|
||||
Labels []string // Label names for the metric
|
||||
Buckets []float64 // Buckets for Histogram type
|
||||
Objectives map[float64]float64 // Objectives for Summary type
|
||||
}
|
||||
```
|
||||
|
||||
## Metric Types
|
||||
|
||||
### Counter
|
||||
Used for cumulative metrics that only increase in value.
|
||||
|
||||
```go
|
||||
MetricDefinition{
|
||||
Type: prometrics.Counter,
|
||||
Name: "requests_total",
|
||||
Help: "Total requests processed",
|
||||
Labels: []string{"endpoint"},
|
||||
}
|
||||
```
|
||||
|
||||
### Gauge
|
||||
Used for metrics that can increase and decrease.
|
||||
|
||||
```go
|
||||
MetricDefinition{
|
||||
Type: prometrics.Gauge,
|
||||
Name: "active_connections",
|
||||
Help: "Current number of active connections",
|
||||
Labels: []string{"server"},
|
||||
}
|
||||
```
|
||||
|
||||
### Histogram
|
||||
Used for measurements like request duration or response size.
|
||||
|
||||
```go
|
||||
MetricDefinition{
|
||||
Type: prometrics.Histogram,
|
||||
Name: "request_duration_seconds",
|
||||
Help: "Request duration in seconds",
|
||||
Labels: []string{"endpoint"},
|
||||
Buckets: []float64{0.1, 0.5, 1, 2, 5},
|
||||
}
|
||||
```
|
||||
|
||||
### Summary
|
||||
Similar to histogram but calculates quantiles over time.
|
||||
|
||||
```go
|
||||
MetricDefinition{
|
||||
Type: prometrics.Summary,
|
||||
Name: "request_latency",
|
||||
Help: "Request latency",
|
||||
Labels: []string{"endpoint"},
|
||||
Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001},
|
||||
}
|
||||
```
|
||||
|
||||
## Recording Metrics
|
||||
|
||||
Use the `RecordMetric` method to record values for any metric type:
|
||||
|
||||
```go
|
||||
err := metrics.RecordMetric("metric_name", value, map[string]string{
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
})
|
||||
```
|
||||
|
||||
The method is thread-safe and will return an error if:
|
||||
- The metric doesn't exist
|
||||
- The label values don't match the metric's label names
|
||||
- The metric type is invalid
|
||||
|
||||
## HTTP Endpoint
|
||||
|
||||
When `ServeHttp` is set to `true` in the configuration, the package automatically serves metrics at `/metrics` on the specified port. This endpoint can be scraped by Prometheus.
|
||||
|
||||
## Lifecycle Management
|
||||
|
||||
The library provides two ways to manage the lifecycle of the metrics server:
|
||||
|
||||
1. Context-based shutdown:
|
||||
```go
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
metrics, err := prometheus.New(ctx, config)
|
||||
// Server will shut down when context is cancelled
|
||||
cancel()
|
||||
```
|
||||
|
||||
2. Manual shutdown:
|
||||
```go
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
err := metrics.Shutdown(shutdownCtx)
|
||||
```
|
||||
|
||||
The metrics server will also automatically shut down when the main process exits, but it's recommended to use one of the above methods for graceful shutdown.
|
||||
|
||||
## Thread Safety
|
||||
|
||||
All operations in the package are thread-safe, allowing for concurrent metric recording from multiple goroutines.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The package returns errors in the following cases:
|
||||
- Invalid metric type specified in configuration
|
||||
- Failed metric registration (e.g., duplicate metrics)
|
||||
- Recording to non-existent metric
|
||||
- Invalid label combinations
|
||||
- Server shutdown failures
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Always provide a context when creating new metrics:
|
||||
```go
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
metrics, err := prometheus.New(ctx, config)
|
||||
```
|
||||
|
||||
2. Implement graceful shutdown in your application:
|
||||
```go
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
if err := metrics.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("Error during metrics shutdown: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
3. Handle server errors appropriately:
|
||||
```go
|
||||
if err := metrics.Shutdown(ctx); err != nil {
|
||||
// Log the error but don't panic - the application can continue
|
||||
log.Printf("Metrics server shutdown error: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
4. Use appropriate timeouts for shutdown:
|
||||
```go
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
metrics.Shutdown(ctx)
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user