Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af7683da9a | |||
| 3c801842e4 | |||
| d56e33b171 | |||
| 614cbf8097 | |||
| 1df4b0d4b4 | |||
| d43ff0f2a9 | |||
| 66a72265ce | |||
| 94b718a925 | |||
| 283d3936f6 | |||
| 7291f00f48 | |||
| 2670d25b52 | |||
| 45308cd4bf | |||
| 4767caf7b8 | |||
| f90281f472 | |||
| ca190b0085 | |||
| 10864363e2 | |||
| 66b51df2af | |||
| 28d05d1d0e | |||
| a774f92bf7 |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.aider*
|
||||
28
.mcp.json
Normal file
28
.mcp.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"context7": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@upstash/context7-mcp@1.0.0"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"serena": {
|
||||
"type": "stdio",
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"--from",
|
||||
"git+https://github.com/oraios/serena@v0.1.4",
|
||||
"serena",
|
||||
"start-mcp-server",
|
||||
"--context",
|
||||
"ide-assistant",
|
||||
"--project",
|
||||
"."
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
.pre-commit-config.yaml
Normal file
20
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-added-large-files
|
||||
args: ["--maxkb=400"]
|
||||
- id: check-case-conflict
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-shebang-scripts-are-executable
|
||||
- id: check-merge-conflict
|
||||
- id: check-symlinks
|
||||
|
||||
- repo: https://github.com/adrienverge/yamllint
|
||||
rev: v1.35.1
|
||||
hooks:
|
||||
- id: yamllint
|
||||
args: [-c=.yamllint]
|
||||
14
.yamllint
Normal file
14
.yamllint
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
extends: relaxed
|
||||
|
||||
rules:
|
||||
braces:
|
||||
level: error
|
||||
brackets:
|
||||
level: error
|
||||
|
||||
truthy:
|
||||
level: warning
|
||||
|
||||
#ignore: |
|
||||
# - ...
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,3 +1,22 @@
|
||||
# Release Notes - v0.5.2
|
||||
|
||||
## Health Package
|
||||
- **Kubernetes-native health probes** - Added dedicated handlers for liveness (`/healthz`), readiness (`/readyz`), and startup (`/startupz`) probes
|
||||
- **Flexible configuration options** - New `WithLivenessHandler`, `WithReadinessHandler`, `WithStartupHandler`, and `WithServiceName` options
|
||||
- **JSON response formats** - Structured probe responses with service identification
|
||||
- **Backward compatibility** - Maintains existing `/__health` and `/` endpoints
|
||||
|
||||
## Logger Package
|
||||
- **Runtime log level control** - Independent level management for stderr and OTLP loggers via `SetLevel()` and `SetOTLPLevel()`
|
||||
- **Environment variable support** - Configure levels with `LOG_LEVEL` and `OTLP_LOG_LEVEL` env vars
|
||||
- **String parsing utility** - New `ParseLevel()` function for converting string levels to `slog.Level`
|
||||
- **Buffering exporter fix** - Resolved "unlock of unlocked mutex" panic in `bufferingExporter`
|
||||
- **Initialization redesign** - Eliminated race conditions in TLS/tracing setup retry logic
|
||||
|
||||
## Database Package
|
||||
- **Configuration file override** - Added `DATABASE_CONFIG_FILE` environment variable to specify custom database configuration file paths
|
||||
- **Flexible path configuration** - Override default `["database.yaml", "/vault/secrets/database.yaml"]` search paths when needed
|
||||
|
||||
# Release Notes - v0.5.1
|
||||
|
||||
## Observability Enhancements
|
||||
|
||||
@@ -39,6 +39,8 @@ type Config struct {
|
||||
webHostnames []string
|
||||
webTLS bool
|
||||
|
||||
poolDomain string `accessor:"getter"`
|
||||
|
||||
valid bool `accessor:"getter"`
|
||||
}
|
||||
|
||||
@@ -52,6 +54,7 @@ type Config struct {
|
||||
// - web_hostname: Comma-separated web hostnames (first becomes primary)
|
||||
// - manage_tls: Management interface TLS setting
|
||||
// - web_tls: Web interface TLS setting
|
||||
// - pool_domain: NTP pool domain (default: pool.ntp.org)
|
||||
func New() *Config {
|
||||
c := Config{}
|
||||
c.deploymentMode = os.Getenv("deployment_mode")
|
||||
@@ -69,6 +72,11 @@ func New() *Config {
|
||||
c.manageTLS = parseBool(os.Getenv("manage_tls"))
|
||||
c.webTLS = parseBool(os.Getenv("web_tls"))
|
||||
|
||||
c.poolDomain = os.Getenv("pool_domain")
|
||||
if c.poolDomain == "" {
|
||||
c.poolDomain = "pool.ntp.org"
|
||||
}
|
||||
|
||||
return &c
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@ func (c *Config) WebHostname() string {
|
||||
return c.webHostname
|
||||
}
|
||||
|
||||
func (c *Config) PoolDomain() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.poolDomain
|
||||
}
|
||||
|
||||
func (c *Config) Valid() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
@@ -8,15 +10,67 @@ import (
|
||||
|
||||
// Config represents the database configuration structure
|
||||
type Config struct {
|
||||
MySQL DBConfig `yaml:"mysql"`
|
||||
// MySQL configuration (use this OR Postgres, not both)
|
||||
MySQL *MySQLConfig `yaml:"mysql,omitempty"`
|
||||
// Postgres configuration (use this OR MySQL, not both)
|
||||
Postgres *PostgresConfig `yaml:"postgres,omitempty"`
|
||||
|
||||
// Legacy flat PostgreSQL format (deprecated, for backward compatibility only)
|
||||
// If neither MySQL nor Postgres is set, these fields will be used for PostgreSQL
|
||||
User string `yaml:"user,omitempty"`
|
||||
Pass string `yaml:"pass,omitempty"`
|
||||
Host string `yaml:"host,omitempty"`
|
||||
Port uint16 `yaml:"port,omitempty"`
|
||||
Name string `yaml:"name,omitempty"`
|
||||
SSLMode string `yaml:"sslmode,omitempty"`
|
||||
}
|
||||
|
||||
// DBConfig represents the MySQL database configuration
|
||||
type DBConfig struct {
|
||||
DSN string `default:"" flag:"dsn" usage:"Database DSN"`
|
||||
User string `default:"" flag:"user"`
|
||||
Pass string `default:"" flag:"pass"`
|
||||
DBName string // Optional database name override
|
||||
// MySQLConfig represents the MySQL database configuration
|
||||
type MySQLConfig struct {
|
||||
DSN string `yaml:"dsn" default:"" flag:"dsn" usage:"Database DSN"`
|
||||
User string `yaml:"user" default:"" flag:"user"`
|
||||
Pass string `yaml:"pass" default:"" flag:"pass"`
|
||||
DBName string `yaml:"name,omitempty"` // Optional database name override
|
||||
}
|
||||
|
||||
// PostgresConfig represents the PostgreSQL database configuration
|
||||
type PostgresConfig struct {
|
||||
User string `yaml:"user"`
|
||||
Pass string `yaml:"pass"`
|
||||
Host string `yaml:"host"`
|
||||
Port uint16 `yaml:"port"`
|
||||
Name string `yaml:"name"`
|
||||
SSLMode string `yaml:"sslmode"`
|
||||
}
|
||||
|
||||
// DBConfig is a legacy alias for MySQLConfig
|
||||
type DBConfig = MySQLConfig
|
||||
|
||||
// Validate ensures the configuration is valid and unambiguous
|
||||
func (c *Config) Validate() error {
|
||||
hasMySQL := c.MySQL != nil
|
||||
hasPostgres := c.Postgres != nil
|
||||
hasLegacy := c.User != "" || c.Host != "" || c.Port != 0 || c.Name != ""
|
||||
|
||||
count := 0
|
||||
if hasMySQL {
|
||||
count++
|
||||
}
|
||||
if hasPostgres {
|
||||
count++
|
||||
}
|
||||
if hasLegacy {
|
||||
count++
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return fmt.Errorf("no database configuration provided")
|
||||
}
|
||||
if count > 1 {
|
||||
return fmt.Errorf("multiple database configurations provided (only one allowed)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigOptions allows customization of database opening behavior
|
||||
@@ -36,10 +90,20 @@ type ConfigOptions struct {
|
||||
ConnMaxLifetime time.Duration
|
||||
}
|
||||
|
||||
// getConfigFiles returns the list of config files to search for database configuration.
|
||||
// If DATABASE_CONFIG_FILE environment variable is set, it returns that single file.
|
||||
// Otherwise, it returns the default paths.
|
||||
func getConfigFiles() []string {
|
||||
if configFile := os.Getenv("DATABASE_CONFIG_FILE"); configFile != "" {
|
||||
return []string{configFile}
|
||||
}
|
||||
return []string{"database.yaml", "/vault/secrets/database.yaml"}
|
||||
}
|
||||
|
||||
// DefaultConfigOptions returns the standard configuration options used by API package
|
||||
func DefaultConfigOptions() ConfigOptions {
|
||||
return ConfigOptions{
|
||||
ConfigFiles: []string{"database.yaml", "/vault/secrets/database.yaml"},
|
||||
ConfigFiles: getConfigFiles(),
|
||||
EnablePoolMonitoring: true,
|
||||
PrometheusRegisterer: prometheus.DefaultRegisterer,
|
||||
MaxOpenConns: 25,
|
||||
@@ -51,7 +115,7 @@ func DefaultConfigOptions() ConfigOptions {
|
||||
// MonitorConfigOptions returns configuration options optimized for Monitor package
|
||||
func MonitorConfigOptions() ConfigOptions {
|
||||
return ConfigOptions{
|
||||
ConfigFiles: []string{"database.yaml", "/vault/secrets/database.yaml"},
|
||||
ConfigFiles: getConfigFiles(),
|
||||
EnablePoolMonitoring: false, // Monitor doesn't need metrics
|
||||
PrometheusRegisterer: nil, // No Prometheus dependency
|
||||
MaxOpenConns: 10,
|
||||
|
||||
@@ -56,9 +56,9 @@ func TestMonitorConfigOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigStructures(t *testing.T) {
|
||||
// Test that configuration structures can be created and populated
|
||||
// Test that MySQL configuration structures can be created and populated
|
||||
config := Config{
|
||||
MySQL: DBConfig{
|
||||
MySQL: &MySQLConfig{
|
||||
DSN: "user:pass@tcp(localhost:3306)/dbname",
|
||||
User: "testuser",
|
||||
Pass: "testpass",
|
||||
@@ -79,3 +79,118 @@ func TestConfigStructures(t *testing.T) {
|
||||
t.Errorf("Expected DBName='testdb', got '%s'", config.MySQL.DBName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresConfigStructures(t *testing.T) {
|
||||
// Test that PostgreSQL configuration structures can be created and populated
|
||||
config := Config{
|
||||
Postgres: &PostgresConfig{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "testuser",
|
||||
Pass: "testpass",
|
||||
Name: "testdb",
|
||||
SSLMode: "require",
|
||||
},
|
||||
}
|
||||
|
||||
if config.Postgres.Host != "localhost" {
|
||||
t.Errorf("Expected Host='localhost', got '%s'", config.Postgres.Host)
|
||||
}
|
||||
if config.Postgres.Port != 5432 {
|
||||
t.Errorf("Expected Port=5432, got %d", config.Postgres.Port)
|
||||
}
|
||||
if config.Postgres.User != "testuser" {
|
||||
t.Errorf("Expected User='testuser', got '%s'", config.Postgres.User)
|
||||
}
|
||||
if config.Postgres.Pass != "testpass" {
|
||||
t.Errorf("Expected Pass='testpass', got '%s'", config.Postgres.Pass)
|
||||
}
|
||||
if config.Postgres.Name != "testdb" {
|
||||
t.Errorf("Expected Name='testdb', got '%s'", config.Postgres.Name)
|
||||
}
|
||||
if config.Postgres.SSLMode != "require" {
|
||||
t.Errorf("Expected SSLMode='require', got '%s'", config.Postgres.SSLMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyPostgresConfig(t *testing.T) {
|
||||
// Test that legacy flat PostgreSQL format can be created
|
||||
config := Config{
|
||||
User: "testuser",
|
||||
Pass: "testpass",
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
Name: "testdb",
|
||||
SSLMode: "require",
|
||||
}
|
||||
|
||||
if config.User != "testuser" {
|
||||
t.Errorf("Expected User='testuser', got '%s'", config.User)
|
||||
}
|
||||
if config.Name != "testdb" {
|
||||
t.Errorf("Expected Name='testdb', got '%s'", config.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid mysql config",
|
||||
config: Config{
|
||||
MySQL: &MySQLConfig{DSN: "test"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid postgres config",
|
||||
config: Config{
|
||||
Postgres: &PostgresConfig{User: "test", Host: "localhost", Name: "test"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid legacy postgres config",
|
||||
config: Config{
|
||||
User: "test",
|
||||
Host: "localhost",
|
||||
Name: "testdb",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "both mysql and postgres set",
|
||||
config: Config{
|
||||
MySQL: &MySQLConfig{DSN: "test"},
|
||||
Postgres: &PostgresConfig{User: "test"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mysql and legacy postgres set",
|
||||
config: Config{
|
||||
MySQL: &MySQLConfig{DSN: "test"},
|
||||
User: "test",
|
||||
Name: "testdb",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no config set",
|
||||
config: Config{},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -48,7 +50,7 @@ func createConnector(configFile string) CreateConnectorFunc {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer dbFile.Close()
|
||||
defer func() { _ = dbFile.Close() }()
|
||||
|
||||
dec := yaml.NewDecoder(dbFile)
|
||||
cfg := Config{}
|
||||
@@ -58,7 +60,28 @@ func createConnector(configFile string) CreateConnectorFunc {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dsn := cfg.MySQL.DSN
|
||||
// Validate configuration
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
// Determine database type and create appropriate connector
|
||||
if cfg.MySQL != nil {
|
||||
return createMySQLConnector(cfg.MySQL)
|
||||
} else if cfg.Postgres != nil {
|
||||
return createPostgresConnector(cfg.Postgres)
|
||||
} else if cfg.User != "" && cfg.Name != "" {
|
||||
// Legacy flat PostgreSQL format (requires at minimum user and dbname)
|
||||
return createPostgresConnectorFromFlat(&cfg)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no valid database configuration found (mysql or postgres section required)")
|
||||
}
|
||||
}
|
||||
|
||||
// createMySQLConnector creates a MySQL connector from configuration
|
||||
func createMySQLConnector(cfg *MySQLConfig) (driver.Connector, error) {
|
||||
dsn := cfg.DSN
|
||||
if len(dsn) == 0 {
|
||||
dsn = os.Getenv("DATABASE_DSN")
|
||||
if len(dsn) == 0 {
|
||||
@@ -71,18 +94,94 @@ func createConnector(configFile string) CreateConnectorFunc {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user := cfg.MySQL.User; len(user) > 0 {
|
||||
if user := cfg.User; len(user) > 0 {
|
||||
dbcfg.User = user
|
||||
}
|
||||
|
||||
if pass := cfg.MySQL.Pass; len(pass) > 0 {
|
||||
if pass := cfg.Pass; len(pass) > 0 {
|
||||
dbcfg.Passwd = pass
|
||||
}
|
||||
|
||||
if name := cfg.MySQL.DBName; len(name) > 0 {
|
||||
if name := cfg.DBName; len(name) > 0 {
|
||||
dbcfg.DBName = name
|
||||
}
|
||||
|
||||
return mysql.NewConnector(dbcfg)
|
||||
}
|
||||
}
|
||||
|
||||
// createPostgresConnector creates a PostgreSQL connector from configuration
|
||||
func createPostgresConnector(cfg *PostgresConfig) (driver.Connector, error) {
|
||||
// Validate required fields
|
||||
if cfg.Host == "" {
|
||||
return nil, fmt.Errorf("postgres: host is required")
|
||||
}
|
||||
if cfg.User == "" {
|
||||
return nil, fmt.Errorf("postgres: user is required")
|
||||
}
|
||||
if cfg.Name == "" {
|
||||
return nil, fmt.Errorf("postgres: database name is required")
|
||||
}
|
||||
|
||||
// Validate SSLMode
|
||||
validSSLModes := map[string]bool{
|
||||
"disable": true, "allow": true, "prefer": true,
|
||||
"require": true, "verify-ca": true, "verify-full": true,
|
||||
}
|
||||
if cfg.SSLMode != "" && !validSSLModes[cfg.SSLMode] {
|
||||
return nil, fmt.Errorf("postgres: invalid sslmode: %s", cfg.SSLMode)
|
||||
}
|
||||
|
||||
// Build config directly (security: no DSN string with password)
|
||||
connConfig, err := pgx.ParseConfig("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("postgres: failed to create pgx config: %w", err)
|
||||
}
|
||||
|
||||
connConfig.Host = cfg.Host
|
||||
connConfig.Port = cfg.Port
|
||||
connConfig.User = cfg.User
|
||||
connConfig.Password = cfg.Pass
|
||||
connConfig.Database = cfg.Name
|
||||
|
||||
// Map SSLMode to pgx configuration
|
||||
// Note: pgx uses different SSL handling than libpq
|
||||
// For now, we'll construct a minimal DSN with sslmode for ParseConfig
|
||||
if cfg.SSLMode != "" {
|
||||
// Reconstruct with sslmode only (no password in DSN)
|
||||
dsnWithoutPassword := fmt.Sprintf("host=%s port=%d user=%s dbname=%s sslmode=%s",
|
||||
cfg.Host, cfg.Port, cfg.User, cfg.Name, cfg.SSLMode)
|
||||
connConfig, err = pgx.ParseConfig(dsnWithoutPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("postgres: failed to parse config with sslmode: %w", err)
|
||||
}
|
||||
// Set password separately after parsing
|
||||
connConfig.Password = cfg.Pass
|
||||
}
|
||||
|
||||
return stdlib.GetConnector(*connConfig), nil
|
||||
}
|
||||
|
||||
// createPostgresConnectorFromFlat creates a PostgreSQL connector from flat config format
|
||||
func createPostgresConnectorFromFlat(cfg *Config) (driver.Connector, error) {
|
||||
pgCfg := &PostgresConfig{
|
||||
User: cfg.User,
|
||||
Pass: cfg.Pass,
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
Name: cfg.Name,
|
||||
SSLMode: cfg.SSLMode,
|
||||
}
|
||||
|
||||
// Set defaults for PostgreSQL
|
||||
if pgCfg.Host == "" {
|
||||
pgCfg.Host = "localhost"
|
||||
}
|
||||
if pgCfg.Port == 0 {
|
||||
pgCfg.Port = 5432
|
||||
}
|
||||
if pgCfg.SSLMode == "" {
|
||||
pgCfg.SSLMode = "prefer"
|
||||
}
|
||||
|
||||
return createPostgresConnector(pgCfg)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ import (
|
||||
)
|
||||
|
||||
// Mock types for testing SQLC integration patterns
|
||||
type mockQueries struct {
|
||||
db DBTX
|
||||
}
|
||||
type mockQueries struct{}
|
||||
|
||||
type mockQueriesTx struct {
|
||||
*mockQueries
|
||||
@@ -58,7 +56,7 @@ func TestSQLCIntegration(t *testing.T) {
|
||||
|
||||
// Verify our DB interface is compatible with what SQLC expects
|
||||
var dbInterface DB[*mockQueriesTx]
|
||||
var mockDB *mockQueries = &mockQueries{}
|
||||
mockDB := &mockQueries{}
|
||||
dbInterface = mockDB
|
||||
|
||||
// Test that our transaction helper can work with this pattern
|
||||
|
||||
@@ -3,10 +3,10 @@ package database
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.ntppool.org/common/logger"
|
||||
)
|
||||
|
||||
// DatabaseMetrics holds the Prometheus metrics for database connection pool monitoring
|
||||
@@ -16,6 +16,10 @@ type DatabaseMetrics struct {
|
||||
ConnectionsInUse prometheus.Gauge
|
||||
ConnectionsWaitCount prometheus.Counter
|
||||
ConnectionsWaitDuration prometheus.Histogram
|
||||
|
||||
// Track last values for delta calculation (cumulative stats from sql.DBStats)
|
||||
lastWaitCount int64
|
||||
lastWaitDuration time.Duration
|
||||
}
|
||||
|
||||
// NewDatabaseMetrics creates a new set of database metrics and registers them
|
||||
@@ -67,26 +71,44 @@ func monitorConnectionPool(ctx context.Context, db *sql.DB, registerer prometheu
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
log := logger.FromContext(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.InfoContext(ctx, "database connection pool monitor stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
stats := db.Stats()
|
||||
|
||||
// Update gauge metrics (current state)
|
||||
metrics.ConnectionsOpen.Set(float64(stats.OpenConnections))
|
||||
metrics.ConnectionsIdle.Set(float64(stats.Idle))
|
||||
metrics.ConnectionsInUse.Set(float64(stats.InUse))
|
||||
metrics.ConnectionsWaitCount.Add(float64(stats.WaitCount))
|
||||
|
||||
if stats.WaitDuration > 0 {
|
||||
metrics.ConnectionsWaitDuration.Observe(stats.WaitDuration.Seconds())
|
||||
// Update counter with delta (WaitCount is cumulative in sql.DBStats)
|
||||
waitCountDelta := stats.WaitCount - metrics.lastWaitCount
|
||||
if waitCountDelta > 0 {
|
||||
metrics.ConnectionsWaitCount.Add(float64(waitCountDelta))
|
||||
metrics.lastWaitCount = stats.WaitCount
|
||||
}
|
||||
|
||||
// Update histogram with delta (WaitDuration is cumulative in sql.DBStats)
|
||||
waitDurationDelta := stats.WaitDuration - metrics.lastWaitDuration
|
||||
if waitDurationDelta > 0 {
|
||||
metrics.ConnectionsWaitDuration.Observe(waitDurationDelta.Seconds())
|
||||
metrics.lastWaitDuration = stats.WaitDuration
|
||||
}
|
||||
|
||||
// Log connection pool stats for high usage or waiting
|
||||
if stats.OpenConnections > 20 || stats.WaitCount > 0 {
|
||||
fmt.Printf("Connection pool stats: open=%d idle=%d in_use=%d wait_count=%d wait_duration=%s\n",
|
||||
stats.OpenConnections, stats.Idle, stats.InUse, stats.WaitCount, stats.WaitDuration)
|
||||
if stats.OpenConnections > 20 || waitCountDelta > 0 {
|
||||
log.WarnContext(ctx, "high database connection usage",
|
||||
"open", stats.OpenConnections,
|
||||
"idle", stats.Idle,
|
||||
"in_use", stats.InUse,
|
||||
"wait_count", stats.WaitCount,
|
||||
"wait_duration", stats.WaitDuration,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
151
database/pgdb/CLAUDE.md
Normal file
151
database/pgdb/CLAUDE.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# pgdb - Native PostgreSQL Connection Pool
|
||||
|
||||
Primary package for PostgreSQL connections using native pgx pool (`*pgxpool.Pool`). Provides better performance and PostgreSQL-specific features compared to `database/sql`.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"go.ntppool.org/common/database/pgdb"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Open pool with default options
|
||||
pool, err := pgdb.OpenPool(ctx, pgdb.DefaultPoolOptions())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// Use the pool for queries
|
||||
row := pool.QueryRow(ctx, "SELECT version()")
|
||||
var version string
|
||||
row.Scan(&version)
|
||||
}
|
||||
```
|
||||
|
||||
### With Custom Config File
|
||||
|
||||
```go
|
||||
pool, err := pgdb.OpenPoolWithConfigFile(ctx, "/path/to/database.yaml")
|
||||
```
|
||||
|
||||
### With Custom Pool Settings
|
||||
|
||||
```go
|
||||
opts := pgdb.DefaultPoolOptions()
|
||||
opts.MaxConns = 50
|
||||
opts.MinConns = 5
|
||||
opts.MaxConnLifetime = 2 * time.Hour
|
||||
|
||||
pool, err := pgdb.OpenPool(ctx, opts)
|
||||
```
|
||||
|
||||
## Configuration Format
|
||||
|
||||
### Recommended: Nested Format (database.yaml)
|
||||
|
||||
```yaml
|
||||
postgres:
|
||||
host: localhost
|
||||
port: 5432
|
||||
user: myuser
|
||||
pass: mypassword
|
||||
name: mydb
|
||||
sslmode: prefer
|
||||
```
|
||||
|
||||
### Legacy: Flat Format (backward compatible)
|
||||
|
||||
```yaml
|
||||
host: localhost
|
||||
port: 5432
|
||||
user: myuser
|
||||
pass: mypassword
|
||||
name: mydb
|
||||
sslmode: prefer
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### PoolOptions
|
||||
|
||||
Defaults match [pgxpool defaults](https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool#Config):
|
||||
|
||||
- `ConfigFiles` - List of config file paths to search (default: `database.yaml`, `/vault/secrets/database.yaml`)
|
||||
- `MinConns` - Minimum connections (default: 0)
|
||||
- `MaxConns` - Maximum connections (default: 4)
|
||||
- `MaxConnLifetime` - Connection lifetime (default: 1 hour)
|
||||
- `MaxConnIdleTime` - Idle timeout (default: 30 minutes)
|
||||
- `HealthCheckPeriod` - Health check interval (default: 1 minute)
|
||||
|
||||
For higher connection limits, set via `PoolOptions` or URI query parameter `?pool_max_conns=25`.
|
||||
|
||||
### PostgreSQL Config Fields
|
||||
|
||||
- `host` - Database host (required)
|
||||
- `user` - Database user (required)
|
||||
- `pass` - Database password
|
||||
- `name` - Database name (required)
|
||||
- `port` - Port number (default: 5432)
|
||||
- `sslmode` - SSL mode: `disable`, `allow`, `prefer`, `require`, `verify-ca`, `verify-full` (default: `prefer`)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `DATABASE_URI` - PostgreSQL connection URI (takes precedence over all other methods)
|
||||
- `DATABASE_CONFIG_FILE` - Override config file location
|
||||
- Standard `PG*` variables (`PGHOST`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, `PGPORT`, `PGSSLMODE`, etc.) - used as fallback when no config file is found; parsed natively by pgx
|
||||
|
||||
### URI Format
|
||||
|
||||
Standard PostgreSQL URI format:
|
||||
```
|
||||
postgresql://user:password@host:port/database?sslmode=require&pool_max_conns=10
|
||||
```
|
||||
|
||||
Pool settings can be included in the URI query string:
|
||||
- `pool_max_conns`, `pool_min_conns`
|
||||
- `pool_max_conn_lifetime`, `pool_max_conn_idle_time`
|
||||
- `pool_health_check_period`
|
||||
|
||||
When using `DATABASE_URI`, pool settings come from the URI. Since `PoolOptions` defaults match pgxpool defaults, behavior is consistent whether using URI or config files.
|
||||
|
||||
Example with CloudNativePG:
|
||||
```yaml
|
||||
# Mount the secret's 'uri' key as DATABASE_URI
|
||||
env:
|
||||
- name: DATABASE_URI
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: mydb-app
|
||||
key: uri
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
**Use `pgdb.OpenPool()`** (this package) when:
|
||||
- You need native PostgreSQL features (LISTEN/NOTIFY, COPY, etc.)
|
||||
- You want better performance
|
||||
- You're writing new PostgreSQL code
|
||||
|
||||
**Use `database.OpenDB()`** (sql.DB) when:
|
||||
- You need database-agnostic code
|
||||
- You're using SQLC or other tools that expect `database/sql`
|
||||
- You need to support both MySQL and PostgreSQL
|
||||
|
||||
## Security
|
||||
|
||||
This package avoids password exposure by:
|
||||
1. Never constructing DSN strings with passwords
|
||||
2. Setting passwords separately in pgx config objects
|
||||
3. Validating all configuration before connection
|
||||
|
||||
## See Also
|
||||
|
||||
- `database/` - Generic sql.DB support for MySQL and PostgreSQL
|
||||
- pgx documentation: https://github.com/jackc/pgx
|
||||
59
database/pgdb/config.go
Normal file
59
database/pgdb/config.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package pgdb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"go.ntppool.org/common/database"
|
||||
)
|
||||
|
||||
// CreatePoolConfig converts database.PostgresConfig to pgxpool.Config
|
||||
// This is the secure way to create a config without exposing passwords in DSN strings
|
||||
func CreatePoolConfig(cfg *database.PostgresConfig) (*pgxpool.Config, error) {
|
||||
// Validate required fields
|
||||
if cfg.Host == "" {
|
||||
return nil, fmt.Errorf("postgres: host is required")
|
||||
}
|
||||
if cfg.User == "" {
|
||||
return nil, fmt.Errorf("postgres: user is required")
|
||||
}
|
||||
if cfg.Name == "" {
|
||||
return nil, fmt.Errorf("postgres: database name is required")
|
||||
}
|
||||
|
||||
// Validate SSLMode
|
||||
validSSLModes := map[string]bool{
|
||||
"disable": true, "allow": true, "prefer": true,
|
||||
"require": true, "verify-ca": true, "verify-full": true,
|
||||
}
|
||||
if cfg.SSLMode != "" && !validSSLModes[cfg.SSLMode] {
|
||||
return nil, fmt.Errorf("postgres: invalid sslmode: %s (valid: disable, allow, prefer, require, verify-ca, verify-full)", cfg.SSLMode)
|
||||
}
|
||||
|
||||
// Apply defaults for optional fields (host is validated as required above)
|
||||
port := cfg.Port
|
||||
if port == 0 {
|
||||
port = 5432
|
||||
}
|
||||
|
||||
sslmode := cfg.SSLMode
|
||||
if sslmode == "" {
|
||||
sslmode = "prefer"
|
||||
}
|
||||
|
||||
// Build connection string WITHOUT password
|
||||
// We'll set the password separately in the config
|
||||
connString := fmt.Sprintf("host=%s port=%d user=%s dbname=%s sslmode=%s",
|
||||
cfg.Host, port, cfg.User, cfg.Name, sslmode)
|
||||
|
||||
// Parse the connection string
|
||||
poolConfig, err := pgxpool.ParseConfig(connString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("postgres: failed to parse connection config: %w", err)
|
||||
}
|
||||
|
||||
// Set password separately (security: never put password in the connection string)
|
||||
poolConfig.ConnConfig.Password = cfg.Pass
|
||||
|
||||
return poolConfig, nil
|
||||
}
|
||||
239
database/pgdb/pool.go
Normal file
239
database/pgdb/pool.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package pgdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"go.ntppool.org/common/database"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// PoolOptions configures pgxpool connection behavior.
|
||||
//
|
||||
// Default values match pgxpool defaults from github.com/jackc/pgx/v5/pgxpool.
|
||||
// See: https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool#Config
|
||||
//
|
||||
// To customize pool settings, either:
|
||||
// - Modify PoolOptions before calling OpenPool (for config file mode)
|
||||
// - Use URI query parameters like ?pool_max_conns=25 (for DATABASE_URI mode)
|
||||
type PoolOptions struct {
|
||||
// ConfigFiles is a list of config file paths to search for database configuration
|
||||
ConfigFiles []string
|
||||
|
||||
// MinConns is the minimum number of connections in the pool.
|
||||
// Default: 0 (matches pgxpool default)
|
||||
MinConns int32
|
||||
|
||||
// MaxConns is the maximum number of connections in the pool.
|
||||
// Default: 4 (matches pgxpool default)
|
||||
// For higher concurrency, increase via PoolOptions or URI ?pool_max_conns=N
|
||||
MaxConns int32
|
||||
|
||||
// MaxConnLifetime is the maximum lifetime of a connection.
|
||||
// Default: 1 hour (matches pgxpool default)
|
||||
MaxConnLifetime time.Duration
|
||||
|
||||
// MaxConnIdleTime is the maximum idle time of a connection.
|
||||
// Default: 30 minutes (matches pgxpool default)
|
||||
MaxConnIdleTime time.Duration
|
||||
|
||||
// HealthCheckPeriod is how often to check connection health.
|
||||
// Default: 1 minute (matches pgxpool default)
|
||||
HealthCheckPeriod time.Duration
|
||||
}
|
||||
|
||||
// DefaultPoolOptions returns defaults matching pgxpool.
|
||||
// See https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool#Config for pgxpool defaults.
|
||||
func DefaultPoolOptions() PoolOptions {
|
||||
return PoolOptions{
|
||||
ConfigFiles: GetConfigFiles(),
|
||||
MinConns: 0,
|
||||
MaxConns: 4,
|
||||
MaxConnLifetime: time.Hour,
|
||||
MaxConnIdleTime: 30 * time.Minute,
|
||||
HealthCheckPeriod: time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// OpenPool opens a native pgx connection pool with the specified configuration
|
||||
// This is the primary and recommended way to connect to PostgreSQL
|
||||
//
|
||||
// Configuration precedence (highest to lowest):
|
||||
// 1. DATABASE_URI environment variable (pool settings can be included in URI)
|
||||
// 2. DATABASE_CONFIG_FILE environment variable (YAML)
|
||||
// 3. Default config files (database.yaml, /vault/secrets/database.yaml)
|
||||
// 4. Standard PG* environment variables (PGHOST, PGUSER, PGPASSWORD, PGDATABASE, etc.)
|
||||
//
|
||||
// When using DATABASE_URI, pool settings (pool_max_conns, pool_min_conns, etc.)
|
||||
// can be specified in the URI query string and PoolOptions are ignored.
|
||||
// When using config files, PoolOptions are applied.
|
||||
func OpenPool(ctx context.Context, options PoolOptions) (*pgxpool.Pool, error) {
|
||||
// Validate PoolOptions
|
||||
if options.MaxConns <= 0 {
|
||||
return nil, fmt.Errorf("pgdb: MaxConns must be positive, got: %d", options.MaxConns)
|
||||
}
|
||||
if options.MinConns < 0 {
|
||||
return nil, fmt.Errorf("pgdb: MinConns must be non-negative, got: %d", options.MinConns)
|
||||
}
|
||||
if options.MinConns > options.MaxConns {
|
||||
return nil, fmt.Errorf("pgdb: MinConns (%d) cannot exceed MaxConns (%d)", options.MinConns, options.MaxConns)
|
||||
}
|
||||
|
||||
var poolConfig *pgxpool.Config
|
||||
var err error
|
||||
|
||||
// Check DATABASE_URI environment variable first (highest priority)
|
||||
// When using DATABASE_URI, pool settings come from URI query parameters
|
||||
// (e.g., ?pool_max_conns=25). PoolOptions are not applied since our defaults
|
||||
// match pgxpool defaults.
|
||||
if uri := os.Getenv("DATABASE_URI"); uri != "" {
|
||||
poolConfig, err = pgxpool.ParseConfig(uri)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse DATABASE_URI: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Fall back to config file approach
|
||||
pgCfg, _, configErr := FindConfig(options.ConfigFiles)
|
||||
if configErr != nil {
|
||||
// No config file found; try standard PG* environment variables
|
||||
// (PGHOST, PGUSER, PGPASSWORD, PGDATABASE, etc.)
|
||||
// pgxpool.ParseConfig("") reads these natively.
|
||||
poolConfig, err = pgxpool.ParseConfig("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w (also tried PG* environment variables: %w)", configErr, err)
|
||||
}
|
||||
} else {
|
||||
poolConfig, err = CreatePoolConfig(pgCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Apply pool-specific settings from PoolOptions (config files and PG* vars don't support these)
|
||||
poolConfig.MinConns = options.MinConns
|
||||
poolConfig.MaxConns = options.MaxConns
|
||||
poolConfig.MaxConnLifetime = options.MaxConnLifetime
|
||||
poolConfig.MaxConnIdleTime = options.MaxConnIdleTime
|
||||
poolConfig.HealthCheckPeriod = options.HealthCheckPeriod
|
||||
}
|
||||
|
||||
// Create the pool
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create connection pool: %w", err)
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close() // pgxpool.Pool.Close() doesn't return an error
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// OpenPoolWithConfigFile opens a connection pool using an explicit config file path
|
||||
// This is a convenience function for when you have a specific config file
|
||||
func OpenPoolWithConfigFile(ctx context.Context, configFile string) (*pgxpool.Pool, error) {
|
||||
options := DefaultPoolOptions()
|
||||
options.ConfigFiles = []string{configFile}
|
||||
return OpenPool(ctx, options)
|
||||
}
|
||||
|
||||
// FindConfig searches for and parses the first existing config file.
|
||||
// Returns the PostgresConfig, the path to the config file used, and any error.
|
||||
// If DATABASE_URI env var is set, returns nil config with empty path (use ParseURIConfig instead).
|
||||
func FindConfig(configFiles []string) (*database.PostgresConfig, string, error) {
|
||||
// Check if DATABASE_URI takes precedence
|
||||
if os.Getenv("DATABASE_URI") != "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
var errs []error
|
||||
var triedFiles []string
|
||||
|
||||
for _, configFile := range configFiles {
|
||||
if configFile == "" {
|
||||
continue
|
||||
}
|
||||
triedFiles = append(triedFiles, configFile)
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(configFile); err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", configFile, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to read and parse the file
|
||||
pgCfg, err := parseConfigFile(configFile)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", configFile, err))
|
||||
continue
|
||||
}
|
||||
|
||||
return pgCfg, configFile, nil
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return nil, "", fmt.Errorf("no valid config file found (tried: %v): %w", triedFiles, errors.Join(errs...))
|
||||
}
|
||||
return nil, "", fmt.Errorf("no valid config files provided")
|
||||
}
|
||||
|
||||
// ParseURIConfig extracts connection info from DATABASE_URI environment variable.
|
||||
// Returns nil if DATABASE_URI is not set.
|
||||
func ParseURIConfig() (*pgxpool.Config, error) {
|
||||
uri := os.Getenv("DATABASE_URI")
|
||||
if uri == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return pgxpool.ParseConfig(uri)
|
||||
}
|
||||
|
||||
// parseConfigFile reads and parses a YAML config file
|
||||
func parseConfigFile(configFile string) (*database.PostgresConfig, error) {
|
||||
file, err := os.Open(configFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open config file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
dec := yaml.NewDecoder(file)
|
||||
cfg := database.Config{}
|
||||
|
||||
if err := dec.Decode(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode config: %w", err)
|
||||
}
|
||||
|
||||
// Extract PostgreSQL config
|
||||
if cfg.Postgres != nil {
|
||||
return cfg.Postgres, nil
|
||||
}
|
||||
|
||||
// Check for legacy flat format
|
||||
if cfg.User != "" && cfg.Name != "" {
|
||||
return &database.PostgresConfig{
|
||||
User: cfg.User,
|
||||
Pass: cfg.Pass,
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
Name: cfg.Name,
|
||||
SSLMode: cfg.SSLMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no PostgreSQL configuration found in %s", configFile)
|
||||
}
|
||||
|
||||
// GetConfigFiles returns the list of config files to search for database configuration.
|
||||
// Checks DATABASE_CONFIG_FILE env var first, otherwise returns default paths.
|
||||
func GetConfigFiles() []string {
|
||||
if configFile := os.Getenv("DATABASE_CONFIG_FILE"); configFile != "" {
|
||||
return []string{configFile}
|
||||
}
|
||||
return []string{"database.yaml", "/vault/secrets/database.yaml"}
|
||||
}
|
||||
295
database/pgdb/pool_test.go
Normal file
295
database/pgdb/pool_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package pgdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"go.ntppool.org/common/database"
|
||||
)
|
||||
|
||||
func TestCreatePoolConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *database.PostgresConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
cfg: &database.PostgresConfig{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "testuser",
|
||||
Pass: "testpass",
|
||||
Name: "testdb",
|
||||
SSLMode: "require",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid config with defaults",
|
||||
cfg: &database.PostgresConfig{
|
||||
Host: "localhost",
|
||||
User: "testuser",
|
||||
Pass: "testpass",
|
||||
Name: "testdb",
|
||||
// Port and SSLMode will use defaults
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing host",
|
||||
cfg: &database.PostgresConfig{
|
||||
User: "testuser",
|
||||
Pass: "testpass",
|
||||
Name: "testdb",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing user",
|
||||
cfg: &database.PostgresConfig{
|
||||
Host: "localhost",
|
||||
Pass: "testpass",
|
||||
Name: "testdb",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing database name",
|
||||
cfg: &database.PostgresConfig{
|
||||
Host: "localhost",
|
||||
User: "testuser",
|
||||
Pass: "testpass",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid sslmode",
|
||||
cfg: &database.PostgresConfig{
|
||||
Host: "localhost",
|
||||
User: "testuser",
|
||||
Pass: "testpass",
|
||||
Name: "testdb",
|
||||
SSLMode: "invalid",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
poolCfg, err := CreatePoolConfig(tt.cfg)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CreatePoolConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr && poolCfg == nil {
|
||||
t.Error("CreatePoolConfig() returned nil config without error")
|
||||
}
|
||||
if !tt.wantErr && poolCfg != nil {
|
||||
// Verify config fields are set correctly
|
||||
if poolCfg.ConnConfig.Host != tt.cfg.Host && tt.cfg.Host != "" {
|
||||
t.Errorf("Expected Host=%s, got %s", tt.cfg.Host, poolCfg.ConnConfig.Host)
|
||||
}
|
||||
if poolCfg.ConnConfig.User != tt.cfg.User {
|
||||
t.Errorf("Expected User=%s, got %s", tt.cfg.User, poolCfg.ConnConfig.User)
|
||||
}
|
||||
if poolCfg.ConnConfig.Password != tt.cfg.Pass {
|
||||
t.Errorf("Expected Password to be set correctly")
|
||||
}
|
||||
if poolCfg.ConnConfig.Database != tt.cfg.Name {
|
||||
t.Errorf("Expected Database=%s, got %s", tt.cfg.Name, poolCfg.ConnConfig.Database)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPoolOptions(t *testing.T) {
|
||||
opts := DefaultPoolOptions()
|
||||
|
||||
// Verify expected defaults
|
||||
if opts.MinConns != 0 {
|
||||
t.Errorf("Expected MinConns=0, got %d", opts.MinConns)
|
||||
}
|
||||
if opts.MaxConns != 4 {
|
||||
t.Errorf("Expected MaxConns=4 (pgxpool default), got %d", opts.MaxConns)
|
||||
}
|
||||
if opts.MaxConnLifetime != time.Hour {
|
||||
t.Errorf("Expected MaxConnLifetime=1h, got %v", opts.MaxConnLifetime)
|
||||
}
|
||||
if opts.MaxConnIdleTime != 30*time.Minute {
|
||||
t.Errorf("Expected MaxConnIdleTime=30m, got %v", opts.MaxConnIdleTime)
|
||||
}
|
||||
if opts.HealthCheckPeriod != time.Minute {
|
||||
t.Errorf("Expected HealthCheckPeriod=1m, got %v", opts.HealthCheckPeriod)
|
||||
}
|
||||
if len(opts.ConfigFiles) == 0 {
|
||||
t.Error("Expected ConfigFiles to be non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePoolConfigDefaults(t *testing.T) {
|
||||
// Test that defaults are applied correctly
|
||||
cfg := &database.PostgresConfig{
|
||||
Host: "localhost",
|
||||
User: "testuser",
|
||||
Pass: "testpass",
|
||||
Name: "testdb",
|
||||
// Port and SSLMode not set
|
||||
}
|
||||
|
||||
poolCfg, err := CreatePoolConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePoolConfig() failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify defaults were applied
|
||||
if poolCfg.ConnConfig.Port != 5432 {
|
||||
t.Errorf("Expected default Port=5432, got %d", poolCfg.ConnConfig.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenPoolWithDatabaseURI(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
// This test requires a running PostgreSQL instance
|
||||
uri := os.Getenv("TEST_DATABASE_URI")
|
||||
if uri == "" {
|
||||
t.Skip("TEST_DATABASE_URI not set")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
t.Setenv("DATABASE_URI", uri)
|
||||
|
||||
pool, err := OpenPool(ctx, DefaultPoolOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("OpenPool failed: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// Verify connection works
|
||||
var result int
|
||||
err = pool.QueryRow(ctx, "SELECT 1").Scan(&result)
|
||||
if err != nil {
|
||||
t.Fatalf("query failed: %v", err)
|
||||
}
|
||||
if result != 1 {
|
||||
t.Errorf("expected 1, got %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGEnvVarsFallback(t *testing.T) {
|
||||
// Verify that pgxpool.ParseConfig("") picks up PG* env vars
|
||||
t.Setenv("PGHOST", "pg.example.com")
|
||||
t.Setenv("PGUSER", "pguser")
|
||||
t.Setenv("PGDATABASE", "pgdb")
|
||||
t.Setenv("PGPORT", "5433")
|
||||
t.Setenv("PGSSLMODE", "require")
|
||||
|
||||
cfg, err := pgxpool.ParseConfig("")
|
||||
if err != nil {
|
||||
t.Fatalf("pgxpool.ParseConfig(\"\") failed: %v", err)
|
||||
}
|
||||
if cfg.ConnConfig.Host != "pg.example.com" {
|
||||
t.Errorf("Expected Host=pg.example.com, got %s", cfg.ConnConfig.Host)
|
||||
}
|
||||
if cfg.ConnConfig.User != "pguser" {
|
||||
t.Errorf("Expected User=pguser, got %s", cfg.ConnConfig.User)
|
||||
}
|
||||
if cfg.ConnConfig.Database != "pgdb" {
|
||||
t.Errorf("Expected Database=pgdb, got %s", cfg.ConnConfig.Database)
|
||||
}
|
||||
if cfg.ConnConfig.Port != 5433 {
|
||||
t.Errorf("Expected Port=5433, got %d", cfg.ConnConfig.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenPoolPGEnvVarsFallback(t *testing.T) {
|
||||
// Unset DATABASE_URI to ensure we don't take that path
|
||||
t.Setenv("DATABASE_URI", "")
|
||||
|
||||
// Set PG* vars pointing to a non-listening port
|
||||
t.Setenv("PGHOST", "127.0.0.1")
|
||||
t.Setenv("PGPORT", "59998")
|
||||
t.Setenv("PGUSER", "testuser")
|
||||
t.Setenv("PGDATABASE", "testdb")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
opts := DefaultPoolOptions()
|
||||
opts.ConfigFiles = []string{"/nonexistent/path/database.yaml"}
|
||||
|
||||
_, err := OpenPool(ctx, opts)
|
||||
|
||||
// Should fail with connection error (not config file error),
|
||||
// proving the PG* fallback path was taken
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "no valid config file") {
|
||||
t.Errorf("expected connection error from PG* fallback, got config file error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGEnvVarsFallbackAppliesPoolOptions(t *testing.T) {
|
||||
// Verify that PoolOptions are applied when using the PG* fallback path.
|
||||
// We can't call OpenPool (it would try to connect), so we verify the
|
||||
// behavior by confirming pgxpool.ParseConfig("") succeeds and that the
|
||||
// code path applies PoolOptions. We test this indirectly via
|
||||
// TestOpenPoolPGEnvVarsFallback (connection error proves PG* path was
|
||||
// taken with pool options applied).
|
||||
|
||||
// pgxpool.ParseConfig("") always succeeds (falls back to libpq defaults),
|
||||
// so when no config files exist the PG* path is always attempted.
|
||||
t.Setenv("DATABASE_URI", "")
|
||||
t.Setenv("PGHOST", "192.0.2.1") // RFC 5737 TEST-NET, won't connect
|
||||
t.Setenv("PGPORT", "5432")
|
||||
t.Setenv("PGUSER", "testuser")
|
||||
t.Setenv("PGDATABASE", "testdb")
|
||||
|
||||
cfg, err := pgxpool.ParseConfig("")
|
||||
if err != nil {
|
||||
t.Fatalf("pgxpool.ParseConfig(\"\") failed: %v", err)
|
||||
}
|
||||
if cfg.ConnConfig.Host != "192.0.2.1" {
|
||||
t.Errorf("Expected Host=192.0.2.1, got %s", cfg.ConnConfig.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseURIPrecedence(t *testing.T) {
|
||||
// Test that DATABASE_URI takes precedence over config files
|
||||
// We use localhost with a port that's unlikely to have postgres running
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Set DATABASE_URI to a parseable URI pointing to a non-listening port
|
||||
t.Setenv("DATABASE_URI", "postgres://testuser:testpass@127.0.0.1:59999/testdb?connect_timeout=1")
|
||||
|
||||
// Set config files to a nonexistent path - if this were used, we'd get
|
||||
// "config file not found" error instead of connection refused
|
||||
opts := DefaultPoolOptions()
|
||||
opts.ConfigFiles = []string{"/nonexistent/path/database.yaml"}
|
||||
|
||||
_, err := OpenPool(ctx, opts)
|
||||
|
||||
// Should fail with connection error (not config file error)
|
||||
// This proves DATABASE_URI was used instead of config files
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
// The error should be about connection failure, not about missing config file
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "config file") || strings.Contains(errStr, "no such file") {
|
||||
t.Errorf("expected connection error, got config file error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,9 @@ func OpenDBWithConfigFile(ctx context.Context, configFile string) (*sql.DB, erro
|
||||
|
||||
// OpenDBMonitor opens a database connection with monitor-specific defaults
|
||||
// This is a convenience function for Monitor package compatibility
|
||||
func OpenDBMonitor() (*sql.DB, error) {
|
||||
func OpenDBMonitor(ctx context.Context) (*sql.DB, error) {
|
||||
options := MonitorConfigOptions()
|
||||
return OpenDB(context.Background(), options)
|
||||
return OpenDB(ctx, options)
|
||||
}
|
||||
|
||||
// findConfigFile searches for the first existing config file from the list
|
||||
|
||||
10
ekko/ekko.go
10
ekko/ekko.go
@@ -162,12 +162,10 @@ func (ek *Ekko) setup(ctx context.Context) (*echo.Echo, error) {
|
||||
},
|
||||
}))
|
||||
|
||||
e.Use(slogecho.NewWithConfig(log,
|
||||
slogecho.Config{
|
||||
WithTraceID: false, // done by logger already
|
||||
Filters: ek.logFilters,
|
||||
},
|
||||
))
|
||||
logConfig := slogecho.DefaultConfig()
|
||||
logConfig.WithTraceID = false // done by logger already
|
||||
logConfig.Filters = ek.logFilters
|
||||
e.Use(slogecho.NewWithConfig(log, logConfig))
|
||||
|
||||
if ek.prom != nil {
|
||||
e.Use(echoprometheus.NewMiddlewareWithConfig(echoprometheus.MiddlewareConfig{
|
||||
|
||||
130
go.mod
130
go.mod
@@ -1,41 +1,42 @@
|
||||
module go.ntppool.org/common
|
||||
|
||||
go 1.23.5
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/abh/certman v0.4.0
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/labstack/echo-contrib v0.17.2
|
||||
github.com/labstack/echo/v4 v4.13.3
|
||||
github.com/oklog/ulid/v2 v2.1.0
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/prometheus/client_model v0.6.1
|
||||
github.com/remychantenay/slog-otel v1.3.2
|
||||
github.com/samber/slog-echo v1.14.8
|
||||
github.com/samber/slog-multi v1.2.4
|
||||
github.com/segmentio/kafka-go v0.4.47
|
||||
github.com/spf13/cobra v1.8.1
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.8.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.58.0
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.58.0
|
||||
go.opentelemetry.io/otel v1.33.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.9.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.9.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.33.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0
|
||||
go.opentelemetry.io/otel/log v0.9.0
|
||||
go.opentelemetry.io/otel/metric v1.33.0
|
||||
go.opentelemetry.io/otel/sdk v1.33.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.9.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.33.0
|
||||
go.opentelemetry.io/otel/trace v1.33.0
|
||||
golang.org/x/mod v0.22.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sync v0.10.0
|
||||
google.golang.org/grpc v1.69.2
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/labstack/echo-contrib v0.17.4
|
||||
github.com/labstack/echo/v4 v4.15.0
|
||||
github.com/oklog/ulid/v2 v2.1.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
github.com/remychantenay/slog-otel v1.3.4
|
||||
github.com/samber/slog-echo v1.18.0
|
||||
github.com/samber/slog-multi v1.6.0
|
||||
github.com/segmentio/kafka-go v0.4.49
|
||||
github.com/spf13/cobra v1.10.2
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.14.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.64.0
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.64.0
|
||||
go.opentelemetry.io/otel v1.39.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0
|
||||
go.opentelemetry.io/otel/log v0.15.0
|
||||
go.opentelemetry.io/otel/metric v1.39.0
|
||||
go.opentelemetry.io/otel/sdk v1.39.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.15.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0
|
||||
go.opentelemetry.io/otel/trace v1.39.0
|
||||
golang.org/x/mod v0.31.0
|
||||
golang.org/x/net v0.48.0
|
||||
golang.org/x/sync v0.19.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -43,38 +44,55 @@ require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/dmarkham/enumer v1.6.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/masaushi/accessory v0.6.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pascaldekloe/name v1.0.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.23 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/common v0.61.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/samber/lo v1.47.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/prometheus/common v0.67.4 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/samber/lo v1.52.0 // indirect
|
||||
github.com/samber/slog-common v0.19.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.58.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.55.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.9.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.33.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/time v0.8.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
|
||||
google.golang.org/protobuf v1.36.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.64.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.15.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/dmarkham/enumer
|
||||
github.com/masaushi/accessory
|
||||
)
|
||||
|
||||
240
go.sum
240
go.sum
@@ -4,36 +4,59 @@ github.com/abh/certman v0.4.0 h1:XHoDtb0YyRQPclaHMrBDlKTVZpNjTK6vhB0S3Bd/Sbs=
|
||||
github.com/abh/certman v0.4.0/go.mod h1:x8QhpKVZifmV1Hdiwdg9gLo2GMPAxezz1s3zrVnPs+I=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
|
||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/dmarkham/enumer v1.6.1 h1:aSc9awYtZL07TUueWs40QcHtxTvHTAwG0EqrNsK45w4=
|
||||
github.com/dmarkham/enumer v1.6.1/go.mod h1:yixql+kDDQRYqcuBM2n9Vlt7NoT9ixgXhaXry8vmRg8=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -42,12 +65,20 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo-contrib v0.17.2 h1:K1zivqmtcC70X9VdBFdLomjPDEVHlrcAObqmuFj1c6w=
|
||||
github.com/labstack/echo-contrib v0.17.2/go.mod h1:NeDh3PX7j/u+jR4iuDt1zHmWZSCz9c/p9mxXcDpyS8E=
|
||||
github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk=
|
||||
github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
|
||||
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/masaushi/accessory v0.6.0 h1:HYAzxkuhfvlbaQwinxXTxsSPbFabAnwHt8K6I/DvNBU=
|
||||
github.com/masaushi/accessory v0.6.0/go.mod h1:8GZMgq3wcIapVZWt7VVQCh5+onPc/8gJeHb8WRXezvQ=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
@@ -55,45 +86,82 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
|
||||
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/pascaldekloe/name v1.0.0 h1:n7LKFgHixETzxpRv2R77YgPUFo85QHGZKrdaYm7eY5U=
|
||||
github.com/pascaldekloe/name v1.0.0/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.23 h1:oJE7T90aYBGtFNrI8+KbETnPymobAhzRrR8Mu8n1yfU=
|
||||
github.com/pierrec/lz4/v4 v4.1.23/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
|
||||
github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
|
||||
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
|
||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||
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/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/remychantenay/slog-otel v1.3.2 h1:ZBx8qnwfLJ6e18Vba4e9Xp9B7khTmpIwFsU1sAmActw=
|
||||
github.com/remychantenay/slog-otel v1.3.2/go.mod h1:gKW4tQ8cGOKoA+bi7wtYba/tcJ6Tc9XyQ/EW8gHA/2E=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/remychantenay/slog-otel v1.3.4 h1:xoM41ayLff2U8zlK5PH31XwD7Lk3W9wKfl4+RcmKom4=
|
||||
github.com/remychantenay/slog-otel v1.3.4/go.mod h1:ZkazuFMICKGDrO0r1njxKRdjTt/YcXKn6v2+0q/b0+U=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
|
||||
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
|
||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89G9YI=
|
||||
github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M=
|
||||
github.com/samber/slog-echo v1.14.8 h1:R7RF2LWEepsKtC7i6A6o9peS3Rz5HO8+H8OD+8mPD1I=
|
||||
github.com/samber/slog-echo v1.14.8/go.mod h1:K21nbusPmai/MYm8PFactmZoFctkMmkeaTdXXyvhY1c=
|
||||
github.com/samber/slog-echo v1.18.0 h1:fnDeUhwqoAsQZxbmIizO0avwE0qjjoefAvhXoByxN3U=
|
||||
github.com/samber/slog-echo v1.18.0/go.mod h1:4diugqPTk6iQdL7gZFJIyf6zGMLVMaGnCmNm+DBSMRU=
|
||||
github.com/samber/slog-multi v1.2.4 h1:k9x3JAWKJFPKffx+oXZ8TasaNuorIW4tG+TXxkt6Ry4=
|
||||
github.com/samber/slog-multi v1.2.4/go.mod h1:ACuZ5B6heK57TfMVkVknN2UZHoFfjCwRxR0Q2OXKHlo=
|
||||
github.com/samber/slog-multi v1.6.0 h1:i1uBY+aaln6ljwdf7Nrt4Sys8Kk6htuYuXDHWJsHtZg=
|
||||
github.com/samber/slog-multi v1.6.0/go.mod h1:qTqzmKdPpT0h4PFsTN5rYRgLwom1v+fNGuIrl1Xnnts=
|
||||
github.com/segmentio/kafka-go v0.4.47 h1:IqziR4pA3vrZq7YdRxaT3w1/5fvIH5qpCwstUanQQB0=
|
||||
github.com/segmentio/kafka-go v0.4.47/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg=
|
||||
github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk=
|
||||
github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
@@ -105,80 +173,118 @@ github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3k
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.8.0 h1:G3sKsNueSdxuACINFxKrQeimAIst0A5ytA2YJH+3e1c=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.8.0/go.mod h1:ptJm3wizguEPurZgarDAwOeX7O0iMR7l+QvIVenhYdE=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.14.0 h1:eypSOd+0txRKCXPNyqLPsbSfA0jULgJcGmSAdFAnrCM=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.14.0/go.mod h1:CRGvIBL/aAxpQU34ZxyQVFlovVcp67s4cAmQu8Jh9mc=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.58.0 h1:gQFwWiqm4JUvOjpdmyU0di+2pVQ8QNpk1Ak/54Y6NcY=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.58.0/go.mod h1:CNyFi9PuvHtEJNmMFHaXZMuA4XmgRXIqpFcHdqzLvVU=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.64.0 h1:7TYhBCu6Xz6vDJGNtEslWZLuuX2IJ/aH50hBY4MVeUg=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.64.0/go.mod h1:tHQctZfAe7e4PBPGyt3kae6mQFXNpj+iiDJa3ithM50=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.58.0 h1:qVsDVgZd/bC6ZKDOHSjILpm0T/BWvASC9cQU3GYga78=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.58.0/go.mod h1:bAv7mY+5qTsFPFaRpr75vDOocX09I36QH4Rg0slEG/U=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.64.0 h1:9pzPj3RFyKOxBAMkM2w84LpT+rdHam1XoFA+QhARiRw=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.64.0/go.mod h1:hlVZx1btWH0XTfXpuGX9dsquB50s+tc3fYFOO5elo2M=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.58.0 h1:DBk8Zh+Yn3WtWCdGSx1pbEV9/naLtjG16c1zwQA2MBI=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.58.0/go.mod h1:DFx32LPclW1MNdSKIMrjjetsk0tJtYhAvuGjDIG2SKE=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.64.0 h1:9PCiXc7BmfD7+BI8POoc3bQSoRSEo01eNqPVu1/+pDY=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.64.0/go.mod h1:NGBbj2Bgb5Oe/35f9WaU3qRnOey+7X+bxnnSS5zzvLA=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.33.0 h1:ig/IsHyyoQ1F1d6FUDIIW5oYpsuTVtN16AyGOgdjAHQ=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.33.0/go.mod h1:EsVYoNy+Eol5znb6wwN3XQTILyjl040gUpEnUSNZfsk=
|
||||
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
|
||||
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.39.0 h1:PI7pt9pkSnimWcp5sQhUA9OzLbc3Ba4sL+VEUTNsxrk=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.9.0 h1:gA2gh+3B3NDvRFP30Ufh7CC3TtJRbUSf2TTD0LbCagw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.9.0/go.mod h1:smRTR+02OtrVGjvWE1sQxhuazozKc/BXvvqqnmOxy+s=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.9.0 h1:Za0Z/j9Gf3Z9DKQ1choU9xI2noCxlkcyFFP2Ob3miEQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.9.0/go.mod h1:jMRB8N75meTNjDFQyJBA/2Z9en21CsxwMctn08NHY6c=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0 h1:W+m0g+/6v3pa5PgVf2xoFMi5YtNR06WtS7ve5pcvLtM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0/go.mod h1:JM31r0GGZ/GU94mX8hN4D8v6e40aFlUECSQ48HaLgHM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0 h1:EKpiGphOYq3CYnIe2eX9ftUkyU+Y8Dtte8OaWyHJ4+I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0/go.mod h1:nWFP7C+T8TygkTjJ7mAyEaFaE7wNfms3nV/vexZ6qt0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0 h1:7F29RDmnlqk6B5d+sUqemt8TBfDqxryYW5gX6L74RFA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0/go.mod h1:ZiGDq7xwDMKmWDrN1XsXAj0iC7hns+2DhxBFSncNHSE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.33.0 h1:bSjzTvsXZbLSWU8hnZXcKmEVaJjjnandxD0PxThhVU8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.33.0/go.mod h1:aj2rilHL8WjXY1I5V+ra+z8FELtk681deydgYT8ikxU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0 h1:nKP4Z2ejtHn3yShBb+2KawiXgpn8In5cT7aO2wXuOTE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0/go.mod h1:NwjeBbNigsO4Aj9WgM0C+cKIrxsZUaRmZUO7A8I7u8o=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.55.0 h1:sSPw658Lk2NWAv74lkD3B/RSDb+xRFx46GjkrL3VUZo=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.55.0/go.mod h1:nC00vyCmQixoeaxF6KNyP42II/RHa9UdruK02qBmHvI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.9.0 h1:iI15wfQb5ZtAVTdS5WROxpYmw6Kjez3hT9SuzXhrgGQ=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.9.0/go.mod h1:yepwlNzVVxHWR5ugHIrll+euPQPq4pvysHTDr/daV9o=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.61.0 h1:cCyZS4dr67d30uDyh8etKM2QyDsQ4zC9ds3bdbrVoD0=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.61.0/go.mod h1:iivMuj3xpR2DkUrUya3TPS/Z9h3dz7h01GxU+fQBRNg=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.15.0 h1:0BSddrtQqLEylcErkeFrJBmwFzcqfQq9+/uxfTZq+HE=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.15.0/go.mod h1:87sjYuAPzaRCtdd09GU5gM1U9wQLrrcYrm77mh5EBoc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.33.0 h1:FiOTYABOX4tdzi8A0+mtzcsTmi6WBOxk66u0f1Mj9Gs=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.33.0/go.mod h1:xyo5rS8DgzV0Jtsht+LCEMwyiDbjpsxBpWETwFRF0/4=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.33.0 h1:W5AWUn/IVe8RFb5pZx1Uh9Laf/4+Qmm4kJL5zPuvR+0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.33.0/go.mod h1:mzKxJywMNBdEX8TSJais3NnsVZUaJ+bAy6UxPTng2vk=
|
||||
go.opentelemetry.io/otel/log v0.9.0 h1:0OiWRefqJ2QszpCiqwGO0u9ajMPe17q6IscQvvp3czY=
|
||||
go.opentelemetry.io/otel/log v0.9.0/go.mod h1:WPP4OJ+RBkQ416jrFCQFuFKtXKD6mOoYCQm6ykK8VaU=
|
||||
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
|
||||
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
|
||||
go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM=
|
||||
go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
|
||||
go.opentelemetry.io/otel/sdk/log v0.9.0 h1:YPCi6W1Eg0vwT/XJWsv2/PaQ2nyAJYuF7UUjQSBe3bc=
|
||||
go.opentelemetry.io/otel/sdk/log v0.9.0/go.mod h1:y0HdrOz7OkXQBuc2yjiqnEHc+CRKeVhRE3hx4RwTmV4=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.33.0 h1:Gs5VK9/WUJhNXZgn8MR6ITatvAmKeIuCtNbsP3JkNqU=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.33.0/go.mod h1:dL5ykHZmm1B1nVRk9dDjChwDmt81MjVp3gLkQRwKf/Q=
|
||||
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
|
||||
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
|
||||
go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
|
||||
go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 h1:8UPA4IbVZxpsD76ihGOQiFml99GPAEZLohDXvqHdi6U=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0/go.mod h1:MZ1T/+51uIVKlRzGw1Fo46KEWThjlCBZKl2LzY5nv4g=
|
||||
go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
|
||||
go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
|
||||
go.opentelemetry.io/otel/log v0.15.0 h1:0VqVnc3MgyYd7QqNVIldC3dsLFKgazR6P3P3+ypkyDY=
|
||||
go.opentelemetry.io/otel/log v0.15.0/go.mod h1:9c/G1zbyZfgu1HmQD7Qj84QMmwTp2QCQsZH1aeoWDE4=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
|
||||
go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
|
||||
go.opentelemetry.io/otel/sdk/log v0.15.0 h1:WgMEHOUt5gjJE93yqfqJOkRflApNif84kxoHWS9VVHE=
|
||||
go.opentelemetry.io/otel/sdk/log v0.15.0/go.mod h1:qDC/FlKQCXfH5hokGsNg9aUBGMJQsrUyeOiW5u+dKBQ=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -189,8 +295,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -203,23 +309,41 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8 h1:st3LcW/BPi75W4q1jJTEor/QWwbNlPlDG0JTn6XhZu0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:klhJGKFyG8Tn50enBn7gizg4nXGXJ+jqEREdCWaPcV4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
|
||||
google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU=
|
||||
google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
|
||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -1,13 +1,71 @@
|
||||
// Package health provides a standalone HTTP server for health checks.
|
||||
//
|
||||
// This package implements a simple health check server that can be used
|
||||
// to expose health status endpoints for monitoring and load balancing.
|
||||
// It supports custom health check handlers and provides structured logging
|
||||
// with graceful shutdown capabilities.
|
||||
// This package implements a flexible health check server that supports
|
||||
// different handlers for Kubernetes probe types (liveness, readiness, startup).
|
||||
// It provides structured logging, graceful shutdown, and standard HTTP endpoints
|
||||
// for monitoring and load balancing.
|
||||
//
|
||||
// # Kubernetes Probe Types
|
||||
//
|
||||
// Liveness Probe: Detects when a container is "dead" and needs restarting.
|
||||
// Should be a lightweight check that verifies the process is still running
|
||||
// and not in an unrecoverable state.
|
||||
//
|
||||
// Readiness Probe: Determines when a container is ready to accept traffic.
|
||||
// Controls which Pods are used as backends for Services. Should verify
|
||||
// the application can handle requests properly.
|
||||
//
|
||||
// Startup Probe: Verifies when a container application has successfully started.
|
||||
// Delays liveness and readiness probes until startup succeeds. Useful for
|
||||
// slow-starting applications.
|
||||
//
|
||||
// # Usage Examples
|
||||
//
|
||||
// Basic usage with a single handler for all probes:
|
||||
//
|
||||
// srv := health.NewServer(myHealthHandler)
|
||||
// srv.Listen(ctx, 9091)
|
||||
//
|
||||
// Advanced usage with separate handlers for each probe type:
|
||||
//
|
||||
// srv := health.NewServer(nil,
|
||||
// health.WithLivenessHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
// // Simple alive check
|
||||
// w.WriteHeader(http.StatusOK)
|
||||
// }),
|
||||
// health.WithReadinessHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
// // Check if ready to serve traffic
|
||||
// if err := checkDatabase(); err != nil {
|
||||
// w.WriteHeader(http.StatusServiceUnavailable)
|
||||
// return
|
||||
// }
|
||||
// w.WriteHeader(http.StatusOK)
|
||||
// }),
|
||||
// health.WithStartupHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
// // Check if startup is complete
|
||||
// if !applicationReady() {
|
||||
// w.WriteHeader(http.StatusServiceUnavailable)
|
||||
// return
|
||||
// }
|
||||
// w.WriteHeader(http.StatusOK)
|
||||
// }),
|
||||
// health.WithServiceName("my-service"),
|
||||
// )
|
||||
// srv.Listen(ctx, 9091)
|
||||
//
|
||||
// # Standard Endpoints
|
||||
//
|
||||
// The server exposes these endpoints:
|
||||
// - /healthz - liveness probe (or general health if no specific handler)
|
||||
// - /readyz - readiness probe (or general health if no specific handler)
|
||||
// - /startupz - startup probe (or general health if no specific handler)
|
||||
// - /__health - general health endpoint (backward compatibility)
|
||||
// - / - general health endpoint (root path)
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -21,23 +79,74 @@ import (
|
||||
// It runs separately from the main application server to ensure health
|
||||
// checks remain available even if the main server is experiencing issues.
|
||||
//
|
||||
// The server includes built-in timeouts, graceful shutdown, and structured
|
||||
// logging for monitoring and debugging health check behavior.
|
||||
// The server supports separate handlers for different Kubernetes probe types
|
||||
// (liveness, readiness, startup) and includes built-in timeouts, graceful
|
||||
// shutdown, and structured logging.
|
||||
type Server struct {
|
||||
log *slog.Logger
|
||||
healthFn http.HandlerFunc
|
||||
livenessHandler http.HandlerFunc
|
||||
readinessHandler http.HandlerFunc
|
||||
startupHandler http.HandlerFunc
|
||||
generalHandler http.HandlerFunc // fallback for /__health and / paths
|
||||
serviceName string
|
||||
}
|
||||
|
||||
// NewServer creates a new health check server with the specified health handler.
|
||||
// If healthFn is nil, a default handler that returns HTTP 200 "ok" is used.
|
||||
func NewServer(healthFn http.HandlerFunc) *Server {
|
||||
// Option represents a configuration option for the health server.
|
||||
type Option func(*Server)
|
||||
|
||||
// WithLivenessHandler sets a specific handler for the /healthz endpoint.
|
||||
// Liveness probes determine if a container should be restarted.
|
||||
func WithLivenessHandler(handler http.HandlerFunc) Option {
|
||||
return func(s *Server) {
|
||||
s.livenessHandler = handler
|
||||
}
|
||||
}
|
||||
|
||||
// WithReadinessHandler sets a specific handler for the /readyz endpoint.
|
||||
// Readiness probes determine if a container can receive traffic.
|
||||
func WithReadinessHandler(handler http.HandlerFunc) Option {
|
||||
return func(s *Server) {
|
||||
s.readinessHandler = handler
|
||||
}
|
||||
}
|
||||
|
||||
// WithStartupHandler sets a specific handler for the /startupz endpoint.
|
||||
// Startup probes determine if a container has finished initializing.
|
||||
func WithStartupHandler(handler http.HandlerFunc) Option {
|
||||
return func(s *Server) {
|
||||
s.startupHandler = handler
|
||||
}
|
||||
}
|
||||
|
||||
// WithServiceName sets the service name for JSON responses and logging.
|
||||
func WithServiceName(serviceName string) Option {
|
||||
return func(s *Server) {
|
||||
s.serviceName = serviceName
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// NewServer creates a new health check server with optional probe-specific handlers.
|
||||
//
|
||||
// If healthFn is provided, it will be used as a fallback for any probe endpoints
|
||||
// that don't have specific handlers configured. If healthFn is nil, a default
|
||||
// handler that returns HTTP 200 "ok" is used as the fallback.
|
||||
//
|
||||
// Use the With* option functions to configure specific handlers for different
|
||||
// probe types (liveness, readiness, startup).
|
||||
func NewServer(healthFn http.HandlerFunc, opts ...Option) *Server {
|
||||
if healthFn == nil {
|
||||
healthFn = basicHealth
|
||||
}
|
||||
srv := &Server{
|
||||
log: logger.Setup(),
|
||||
healthFn: healthFn,
|
||||
generalHandler: healthFn,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(srv)
|
||||
}
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
@@ -47,13 +156,27 @@ func (srv *Server) SetLogger(log *slog.Logger) {
|
||||
}
|
||||
|
||||
// Listen starts the health server on the specified port and blocks until ctx is cancelled.
|
||||
// The server exposes the health handler at "/__health" with graceful shutdown support.
|
||||
// The server exposes health check endpoints with graceful shutdown support.
|
||||
//
|
||||
// Standard endpoints exposed:
|
||||
// - /healthz - liveness probe (uses livenessHandler or falls back to generalHandler)
|
||||
// - /readyz - readiness probe (uses readinessHandler or falls back to generalHandler)
|
||||
// - /startupz - startup probe (uses startupHandler or falls back to generalHandler)
|
||||
// - /__health - general health endpoint (uses generalHandler)
|
||||
// - / - root health endpoint (uses generalHandler)
|
||||
func (srv *Server) Listen(ctx context.Context, port int) error {
|
||||
srv.log.Info("starting health listener", "port", port)
|
||||
|
||||
serveMux := http.NewServeMux()
|
||||
|
||||
serveMux.HandleFunc("/__health", srv.healthFn)
|
||||
// Register probe-specific handlers
|
||||
serveMux.HandleFunc("/healthz", srv.createProbeHandler("liveness"))
|
||||
serveMux.HandleFunc("/readyz", srv.createProbeHandler("readiness"))
|
||||
serveMux.HandleFunc("/startupz", srv.createProbeHandler("startup"))
|
||||
|
||||
// Register general health endpoints for backward compatibility
|
||||
serveMux.HandleFunc("/__health", srv.createGeneralHandler())
|
||||
serveMux.HandleFunc("/", srv.createGeneralHandler())
|
||||
|
||||
hsrv := &http.Server{
|
||||
Addr: ":" + strconv.Itoa(port),
|
||||
@@ -89,6 +212,121 @@ func (srv *Server) Listen(ctx context.Context, port int) error {
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
// createProbeHandler creates a handler for a specific probe type that provides
|
||||
// appropriate JSON responses and falls back to the general handler if no specific
|
||||
// handler is configured.
|
||||
func (srv *Server) createProbeHandler(probeType string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var handler http.HandlerFunc
|
||||
|
||||
// Select the appropriate handler
|
||||
switch probeType {
|
||||
case "liveness":
|
||||
handler = srv.livenessHandler
|
||||
case "readiness":
|
||||
handler = srv.readinessHandler
|
||||
case "startup":
|
||||
handler = srv.startupHandler
|
||||
}
|
||||
|
||||
// Fall back to general handler if no specific handler is configured
|
||||
if handler == nil {
|
||||
handler = srv.generalHandler
|
||||
}
|
||||
|
||||
// Create a response recorder to capture the handler's status code
|
||||
recorder := &statusRecorder{ResponseWriter: w, statusCode: 200}
|
||||
handler(recorder, r)
|
||||
|
||||
// If the handler already wrote a response, we're done
|
||||
if recorder.written {
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, provide a standard JSON response based on the status code
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if recorder.statusCode >= 400 {
|
||||
// Handler indicated unhealthy
|
||||
switch probeType {
|
||||
case "liveness":
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "unhealthy"})
|
||||
case "readiness":
|
||||
json.NewEncoder(w).Encode(map[string]bool{"ready": false})
|
||||
case "startup":
|
||||
json.NewEncoder(w).Encode(map[string]bool{"started": false})
|
||||
}
|
||||
} else {
|
||||
// Handler indicated healthy
|
||||
switch probeType {
|
||||
case "liveness":
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "alive"})
|
||||
case "readiness":
|
||||
json.NewEncoder(w).Encode(map[string]bool{"ready": true})
|
||||
case "startup":
|
||||
json.NewEncoder(w).Encode(map[string]bool{"started": true})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createGeneralHandler creates a handler for general health endpoints that provides
|
||||
// comprehensive health information.
|
||||
func (srv *Server) createGeneralHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Create a response recorder to capture the handler's status code
|
||||
// Use a buffer to prevent the handler from writing to the actual response
|
||||
recorder := &statusRecorder{ResponseWriter: &discardWriter{}, statusCode: 200}
|
||||
srv.generalHandler(recorder, r)
|
||||
|
||||
// Always provide a comprehensive JSON response for general endpoints
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(recorder.statusCode)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"status": map[bool]string{true: "healthy", false: "unhealthy"}[recorder.statusCode < 400],
|
||||
}
|
||||
|
||||
if srv.serviceName != "" {
|
||||
response["service"] = srv.serviceName
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
}
|
||||
|
||||
// statusRecorder captures the response status code from handlers while allowing
|
||||
// them to write their own response content if needed.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
written bool
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.statusCode = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(data []byte) (int, error) {
|
||||
r.written = true
|
||||
return r.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
// discardWriter implements http.ResponseWriter but discards all writes.
|
||||
// Used to capture status codes without writing response content.
|
||||
type discardWriter struct{}
|
||||
|
||||
func (d *discardWriter) Header() http.Header {
|
||||
return make(http.Header)
|
||||
}
|
||||
|
||||
func (d *discardWriter) Write([]byte) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (d *discardWriter) WriteHeader(int) {}
|
||||
|
||||
// HealthCheckListener runs a simple HTTP server on the specified port for health check probes.
|
||||
func HealthCheckListener(ctx context.Context, port int, log *slog.Logger) error {
|
||||
srv := NewServer(nil)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHealthHandler(t *testing.T) {
|
||||
func TestBasicHealthHandler(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/__health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -24,3 +25,129 @@ func TestHealthHandler(t *testing.T) {
|
||||
t.Errorf("expected ok got %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeHandlers(t *testing.T) {
|
||||
// Test with separate handlers for each probe type
|
||||
srv := NewServer(nil,
|
||||
WithLivenessHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
WithReadinessHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
WithStartupHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
WithServiceName("test-service"),
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
handler http.HandlerFunc
|
||||
expectedStatus int
|
||||
expectedBody string
|
||||
}{
|
||||
{srv.createProbeHandler("liveness"), 200, `{"status":"alive"}`},
|
||||
{srv.createProbeHandler("readiness"), 200, `{"ready":true}`},
|
||||
{srv.createProbeHandler("startup"), 200, `{"started":true}`},
|
||||
{srv.createGeneralHandler(), 200, `{"service":"test-service","status":"healthy"}`},
|
||||
}
|
||||
|
||||
for i, tt := range tests {
|
||||
t.Run(fmt.Sprintf("test_%d", i), func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
tt.handler(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if body != tt.expectedBody+"\n" { // json.Encoder adds newline
|
||||
t.Errorf("expected body %q, got %q", tt.expectedBody, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeHandlerFallback(t *testing.T) {
|
||||
// Test fallback to general handler when no specific handler is configured
|
||||
generalHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
srv := NewServer(generalHandler, WithServiceName("test-service"))
|
||||
|
||||
tests := []struct {
|
||||
handler http.HandlerFunc
|
||||
expectedStatus int
|
||||
expectedBody string
|
||||
}{
|
||||
{srv.createProbeHandler("liveness"), 200, `{"status":"alive"}`},
|
||||
{srv.createProbeHandler("readiness"), 200, `{"ready":true}`},
|
||||
{srv.createProbeHandler("startup"), 200, `{"started":true}`},
|
||||
}
|
||||
|
||||
for i, tt := range tests {
|
||||
t.Run(fmt.Sprintf("fallback_%d", i), func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
tt.handler(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if body != tt.expectedBody+"\n" { // json.Encoder adds newline
|
||||
t.Errorf("expected body %q, got %q", tt.expectedBody, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnhealthyProbeHandlers(t *testing.T) {
|
||||
// Test with handlers that return unhealthy status
|
||||
srv := NewServer(nil,
|
||||
WithLivenessHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}),
|
||||
WithReadinessHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}),
|
||||
WithStartupHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}),
|
||||
WithServiceName("test-service"),
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
handler http.HandlerFunc
|
||||
expectedStatus int
|
||||
expectedBody string
|
||||
}{
|
||||
{srv.createProbeHandler("liveness"), 503, `{"status":"unhealthy"}`},
|
||||
{srv.createProbeHandler("readiness"), 503, `{"ready":false}`},
|
||||
{srv.createProbeHandler("startup"), 503, `{"started":false}`},
|
||||
}
|
||||
|
||||
for i, tt := range tests {
|
||||
t.Run(fmt.Sprintf("unhealthy_%d", i), func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
tt.handler(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if body != tt.expectedBody+"\n" { // json.Encoder adds newline
|
||||
t.Errorf("expected body %q, got %q", tt.expectedBody, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
52
internal/tracerconfig/auth.go
Normal file
52
internal/tracerconfig/auth.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package tracerconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// bearerCredentials implements gRPC PerRPCCredentials for bearer token authentication.
|
||||
// It is safe for concurrent use as required by the gRPC PerRPCCredentials interface.
|
||||
type bearerCredentials struct {
|
||||
tokenFunc BearerTokenFunc
|
||||
}
|
||||
|
||||
// GetRequestMetadata returns authorization metadata for each RPC call.
|
||||
// It calls the token function to retrieve the current token.
|
||||
func (c *bearerCredentials) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
|
||||
token, err := c.tokenFunc(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token == "" {
|
||||
return nil, nil // Omit header for empty token
|
||||
}
|
||||
return map[string]string{"authorization": "Bearer " + token}, nil
|
||||
}
|
||||
|
||||
// RequireTransportSecurity returns true because bearer tokens require TLS.
|
||||
func (c *bearerCredentials) RequireTransportSecurity() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// bearerRoundTripper wraps an http.RoundTripper to add bearer token authentication.
|
||||
// It is safe for concurrent use as required by the http.RoundTripper interface.
|
||||
type bearerRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
tokenFunc BearerTokenFunc
|
||||
}
|
||||
|
||||
// RoundTrip adds the Authorization header with the bearer token.
|
||||
func (rt *bearerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
token, err := rt.tokenFunc(req.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token == "" {
|
||||
return rt.base.RoundTrip(req)
|
||||
}
|
||||
// Clone only when adding a header to preserve the original request
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
return rt.base.RoundTrip(req)
|
||||
}
|
||||
326
internal/tracerconfig/auth_test.go
Normal file
326
internal/tracerconfig/auth_test.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package tracerconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBearerCredentials_GetRequestMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tokenFunc BearerTokenFunc
|
||||
wantMeta map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid token",
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "test-token-123", nil
|
||||
},
|
||||
wantMeta: map[string]string{"authorization": "Bearer test-token-123"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty token returns nil map",
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
wantMeta: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "token function error",
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "", errors.New("token retrieval failed")
|
||||
},
|
||||
wantMeta: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &bearerCredentials{tokenFunc: tt.tokenFunc}
|
||||
meta, err := c.GetRequestMetadata(context.Background())
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GetRequestMetadata() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantMeta == nil && meta != nil {
|
||||
t.Errorf("GetRequestMetadata() = %v, want nil", meta)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantMeta != nil {
|
||||
if meta == nil {
|
||||
t.Errorf("GetRequestMetadata() = nil, want %v", tt.wantMeta)
|
||||
return
|
||||
}
|
||||
for k, v := range tt.wantMeta {
|
||||
if meta[k] != v {
|
||||
t.Errorf("GetRequestMetadata()[%q] = %q, want %q", k, meta[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerCredentials_GetRequestMetadata_ContextPassed(t *testing.T) {
|
||||
type ctxKey string
|
||||
key := ctxKey("test-key")
|
||||
expectedValue := "test-value"
|
||||
|
||||
var receivedCtx context.Context
|
||||
c := &bearerCredentials{
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
receivedCtx = ctx
|
||||
return "token", nil
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.WithValue(context.Background(), key, expectedValue)
|
||||
_, err := c.GetRequestMetadata(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRequestMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
if receivedCtx == nil {
|
||||
t.Fatal("context was not passed to tokenFunc")
|
||||
}
|
||||
|
||||
if receivedCtx.Value(key) != expectedValue {
|
||||
t.Errorf("context value = %v, want %v", receivedCtx.Value(key), expectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerCredentials_RequireTransportSecurity(t *testing.T) {
|
||||
c := &bearerCredentials{tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "", nil
|
||||
}}
|
||||
|
||||
if !c.RequireTransportSecurity() {
|
||||
t.Error("RequireTransportSecurity() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerRoundTripper_RoundTrip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tokenFunc BearerTokenFunc
|
||||
wantAuthHeader string
|
||||
wantErr bool
|
||||
serverShouldRun bool
|
||||
}{
|
||||
{
|
||||
name: "adds authorization header with valid token",
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "test-token-abc", nil
|
||||
},
|
||||
wantAuthHeader: "Bearer test-token-abc",
|
||||
wantErr: false,
|
||||
serverShouldRun: true,
|
||||
},
|
||||
{
|
||||
name: "omits header for empty token",
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
wantAuthHeader: "",
|
||||
wantErr: false,
|
||||
serverShouldRun: true,
|
||||
},
|
||||
{
|
||||
name: "propagates token function errors",
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "", errors.New("token error")
|
||||
},
|
||||
wantAuthHeader: "",
|
||||
wantErr: true,
|
||||
serverShouldRun: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var receivedAuthHeader string
|
||||
serverCalled := false
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
serverCalled = true
|
||||
receivedAuthHeader = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
rt := &bearerRoundTripper{
|
||||
base: http.DefaultTransport,
|
||||
tokenFunc: tt.tokenFunc,
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), "GET", server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := rt.RoundTrip(req)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("RoundTrip() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.serverShouldRun {
|
||||
if !serverCalled {
|
||||
t.Error("expected server to be called but it wasn't")
|
||||
return
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if serverCalled && receivedAuthHeader != tt.wantAuthHeader {
|
||||
t.Errorf("Authorization header = %q, want %q", receivedAuthHeader, tt.wantAuthHeader)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerRoundTripper_PreservesOriginalRequest(t *testing.T) {
|
||||
originalHeader := "original-value"
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
rt := &bearerRoundTripper{
|
||||
base: http.DefaultTransport,
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "new-token", nil
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), "GET", server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Custom", originalHeader)
|
||||
|
||||
resp, err := rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("RoundTrip() error = %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Original request should not be modified
|
||||
if auth := req.Header.Get("Authorization"); auth != "" {
|
||||
t.Errorf("original request Authorization header was modified to %q", auth)
|
||||
}
|
||||
|
||||
if custom := req.Header.Get("X-Custom"); custom != originalHeader {
|
||||
t.Errorf("original request X-Custom header = %q, want %q", custom, originalHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerRoundTripper_UsesRequestContext(t *testing.T) {
|
||||
type ctxKey string
|
||||
key := ctxKey("test-key")
|
||||
expectedValue := "context-value"
|
||||
|
||||
var receivedCtx context.Context
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
rt := &bearerRoundTripper{
|
||||
base: http.DefaultTransport,
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
receivedCtx = ctx
|
||||
return "token", nil
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.WithValue(context.Background(), key, expectedValue)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("RoundTrip() error = %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if receivedCtx == nil {
|
||||
t.Fatal("context was not passed to tokenFunc")
|
||||
}
|
||||
|
||||
if receivedCtx.Value(key) != expectedValue {
|
||||
t.Errorf("context value = %v, want %v", receivedCtx.Value(key), expectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerRoundTripper_PreservesRequestBody(t *testing.T) {
|
||||
expectedBody := "request body content"
|
||||
var receivedBody string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
receivedBody = string(body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
rt := &bearerRoundTripper{
|
||||
base: http.DefaultTransport,
|
||||
tokenFunc: func(ctx context.Context) (string, error) {
|
||||
return "token", nil
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), "POST", server.URL,
|
||||
io.NopCloser(newStringReader(expectedBody)))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.ContentLength = int64(len(expectedBody))
|
||||
|
||||
resp, err := rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("RoundTrip() error = %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if receivedBody != expectedBody {
|
||||
t.Errorf("body = %q, want %q", receivedBody, expectedBody)
|
||||
}
|
||||
}
|
||||
|
||||
// stringReader is an io.Reader that reads from a string exactly once
|
||||
type stringReader struct {
|
||||
s string
|
||||
done bool
|
||||
}
|
||||
|
||||
func newStringReader(s string) *stringReader {
|
||||
return &stringReader{s: s}
|
||||
}
|
||||
|
||||
func (r *stringReader) Read(p []byte) (n int, err error) {
|
||||
if r.done {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n = copy(p, r.s)
|
||||
r.done = true
|
||||
return n, io.EOF
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
sdklog "go.opentelemetry.io/otel/sdk/log"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
@@ -109,6 +111,19 @@ func ValidateAndStore(ctx context.Context, cfg *Config, logFactory LogExporterFa
|
||||
// client certificate authentication.
|
||||
type GetClientCertificate func(*tls.CertificateRequestInfo) (*tls.Certificate, error)
|
||||
|
||||
// BearerTokenFunc retrieves a bearer token for OTLP authentication.
|
||||
// It is called for each export request (traces, logs, metrics).
|
||||
// The caller is responsible for caching and token renewal.
|
||||
// Returns the token string (without "Bearer " prefix) or an error.
|
||||
// An empty string with no error means skip the Authorization header.
|
||||
//
|
||||
// Thread safety: This function may be called concurrently from multiple
|
||||
// goroutines. Implementations must be safe for concurrent use.
|
||||
//
|
||||
// Protocol support: Bearer authentication is supported for both gRPC
|
||||
// (via PerRPCCredentials) and HTTP (via custom RoundTripper) exporters.
|
||||
type BearerTokenFunc func(ctx context.Context) (string, error)
|
||||
|
||||
// Config provides configuration options for OpenTelemetry tracing setup.
|
||||
// It supplements standard OpenTelemetry environment variables with additional
|
||||
// NTP Pool-specific configuration including TLS settings for secure OTLP export.
|
||||
@@ -119,6 +134,7 @@ type Config struct {
|
||||
EndpointURL string // Complete OTLP endpoint URL (e.g., "https://otlp.example.com:4317/v1/traces")
|
||||
CertificateProvider GetClientCertificate // Client certificate provider for mutual TLS
|
||||
RootCAs *x509.CertPool // CA certificate pool for server verification
|
||||
BearerTokenFunc BearerTokenFunc // Token provider for bearer authentication
|
||||
}
|
||||
|
||||
// LogExporterFactory creates an OTLP log exporter using the provided configuration.
|
||||
@@ -229,6 +245,27 @@ func getProtocol(signalSpecificEnv string) string {
|
||||
return proto
|
||||
}
|
||||
|
||||
// getHTTPClient builds an HTTP client with bearer auth and/or TLS if configured.
|
||||
// Returns nil if neither is configured, allowing the SDK to use its defaults.
|
||||
func getHTTPClient(cfg *Config, tlsConfig *tls.Config) *http.Client {
|
||||
if cfg.BearerTokenFunc == nil && tlsConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var transport http.RoundTripper = http.DefaultTransport
|
||||
if tlsConfig != nil {
|
||||
transport = &http.Transport{TLSClientConfig: tlsConfig}
|
||||
}
|
||||
if cfg.BearerTokenFunc != nil {
|
||||
transport = &bearerRoundTripper{
|
||||
base: transport,
|
||||
tokenFunc: cfg.BearerTokenFunc,
|
||||
}
|
||||
}
|
||||
|
||||
return &http.Client{Transport: transport}
|
||||
}
|
||||
|
||||
// CreateOTLPLogExporter creates an OTLP log exporter using the provided configuration.
|
||||
func CreateOTLPLogExporter(ctx context.Context, cfg *Config) (sdklog.Exporter, error) {
|
||||
tlsConfig := getTLSConfig(cfg)
|
||||
@@ -242,6 +279,10 @@ func CreateOTLPLogExporter(ctx context.Context, cfg *Config) (sdklog.Exporter, e
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlploggrpc.WithTLSCredentials(credentials.NewTLS(tlsConfig)))
|
||||
}
|
||||
if cfg.BearerTokenFunc != nil {
|
||||
creds := &bearerCredentials{tokenFunc: cfg.BearerTokenFunc}
|
||||
opts = append(opts, otlploggrpc.WithDialOption(grpc.WithPerRPCCredentials(creds)))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlploggrpc.WithEndpoint(cfg.Endpoint))
|
||||
}
|
||||
@@ -254,8 +295,8 @@ func CreateOTLPLogExporter(ctx context.Context, cfg *Config) (sdklog.Exporter, e
|
||||
opts := []otlploghttp.Option{
|
||||
otlploghttp.WithCompression(otlploghttp.GzipCompression),
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlploghttp.WithTLSClientConfig(tlsConfig))
|
||||
if client := getHTTPClient(cfg, tlsConfig); client != nil {
|
||||
opts = append(opts, otlploghttp.WithHTTPClient(client))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlploghttp.WithEndpoint(cfg.Endpoint))
|
||||
@@ -290,6 +331,10 @@ func CreateOTLPMetricExporter(ctx context.Context, cfg *Config) (sdkmetric.Expor
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlpmetricgrpc.WithTLSCredentials(credentials.NewTLS(tlsConfig)))
|
||||
}
|
||||
if cfg.BearerTokenFunc != nil {
|
||||
creds := &bearerCredentials{tokenFunc: cfg.BearerTokenFunc}
|
||||
opts = append(opts, otlpmetricgrpc.WithDialOption(grpc.WithPerRPCCredentials(creds)))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlpmetricgrpc.WithEndpoint(cfg.Endpoint))
|
||||
}
|
||||
@@ -302,8 +347,8 @@ func CreateOTLPMetricExporter(ctx context.Context, cfg *Config) (sdkmetric.Expor
|
||||
opts := []otlpmetrichttp.Option{
|
||||
otlpmetrichttp.WithCompression(otlpmetrichttp.GzipCompression),
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlpmetrichttp.WithTLSClientConfig(tlsConfig))
|
||||
if client := getHTTPClient(cfg, tlsConfig); client != nil {
|
||||
opts = append(opts, otlpmetrichttp.WithHTTPClient(client))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlpmetrichttp.WithEndpoint(cfg.Endpoint))
|
||||
@@ -340,6 +385,10 @@ func CreateOTLPTraceExporter(ctx context.Context, cfg *Config) (sdktrace.SpanExp
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlptracegrpc.WithTLSCredentials(credentials.NewTLS(tlsConfig)))
|
||||
}
|
||||
if cfg.BearerTokenFunc != nil {
|
||||
creds := &bearerCredentials{tokenFunc: cfg.BearerTokenFunc}
|
||||
opts = append(opts, otlptracegrpc.WithDialOption(grpc.WithPerRPCCredentials(creds)))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlptracegrpc.WithEndpoint(cfg.Endpoint))
|
||||
}
|
||||
@@ -352,8 +401,8 @@ func CreateOTLPTraceExporter(ctx context.Context, cfg *Config) (sdktrace.SpanExp
|
||||
opts := []otlptracehttp.Option{
|
||||
otlptracehttp.WithCompression(otlptracehttp.GzipCompression),
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlptracehttp.WithTLSClientConfig(tlsConfig))
|
||||
if httpClient := getHTTPClient(cfg, tlsConfig); httpClient != nil {
|
||||
opts = append(opts, otlptracehttp.WithHTTPClient(httpClient))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlptracehttp.WithEndpoint(cfg.Endpoint))
|
||||
|
||||
@@ -23,8 +23,10 @@ type bufferingExporter struct {
|
||||
// Real exporter (created when tracing is configured)
|
||||
exporter otellog.Exporter
|
||||
|
||||
// Thread-safe initialization
|
||||
initOnce sync.Once
|
||||
// Track whether buffer has been flushed (separate from exporter creation)
|
||||
bufferFlushed bool
|
||||
|
||||
// Thread-safe initialization state (managed only by checkReadiness)
|
||||
initErr error
|
||||
|
||||
// Background checker
|
||||
@@ -48,13 +50,7 @@ func newBufferingExporter() *bufferingExporter {
|
||||
|
||||
// Export implements otellog.Exporter
|
||||
func (e *bufferingExporter) Export(ctx context.Context, records []otellog.Record) error {
|
||||
// Try initialization once
|
||||
e.initOnce.Do(func() {
|
||||
e.initErr = e.initialize()
|
||||
})
|
||||
|
||||
// If initialization succeeded, use the exporter
|
||||
if e.initErr == nil {
|
||||
// Check if exporter is ready (initialization handled by checkReadiness goroutine)
|
||||
e.mu.RLock()
|
||||
exporter := e.exporter
|
||||
e.mu.RUnlock()
|
||||
@@ -62,7 +58,6 @@ func (e *bufferingExporter) Export(ctx context.Context, records []otellog.Record
|
||||
if exporter != nil {
|
||||
return exporter.Export(ctx, records)
|
||||
}
|
||||
}
|
||||
|
||||
// Not ready yet, buffer the records
|
||||
return e.bufferRecords(records)
|
||||
@@ -79,22 +74,63 @@ func (e *bufferingExporter) initialize() error {
|
||||
initCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
e.mu.RLock()
|
||||
hasExporter := e.exporter != nil
|
||||
e.mu.RUnlock()
|
||||
|
||||
// Create exporter if not already created
|
||||
if !hasExporter {
|
||||
exporter, err := factory(initCtx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create OTLP exporter: %w", err)
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
// Double-check: another goroutine may have created it while we were waiting
|
||||
if e.exporter == nil {
|
||||
e.exporter = exporter
|
||||
flushErr := e.flushBuffer(initCtx)
|
||||
} else {
|
||||
// Another goroutine beat us, close the one we created
|
||||
_ = exporter.Shutdown(context.Background())
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
if flushErr != nil {
|
||||
// Log but don't fail initialization
|
||||
Setup().Warn("buffer flush failed during initialization", "error", flushErr)
|
||||
}
|
||||
|
||||
// Check if we can flush (token verification if configured)
|
||||
if !e.canFlush(initCtx, cfg, false) {
|
||||
return errors.New("waiting for token authentication")
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
if !e.bufferFlushed {
|
||||
flushErr := e.flushBuffer(initCtx)
|
||||
if flushErr != nil {
|
||||
e.mu.Unlock()
|
||||
// Log but don't fail initialization
|
||||
Setup().Warn("buffer flush failed during initialization", "error", flushErr)
|
||||
return nil
|
||||
}
|
||||
e.bufferFlushed = true
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// canFlush checks if we're ready to flush buffered logs.
|
||||
// If BearerTokenFunc is configured, it must return without error.
|
||||
// If forceFlush is true (during shutdown with cancelled context), skip token check.
|
||||
func (e *bufferingExporter) canFlush(ctx context.Context, cfg *tracerconfig.Config, forceFlush bool) bool {
|
||||
if cfg.BearerTokenFunc == nil {
|
||||
return true // No token auth configured, can flush immediately
|
||||
}
|
||||
|
||||
if forceFlush {
|
||||
return true // During shutdown, proceed with best-effort flush
|
||||
}
|
||||
|
||||
// Check if token is available (call returns without error)
|
||||
_, err := cfg.BearerTokenFunc(ctx)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// bufferRecords adds records to the buffer for later processing
|
||||
@@ -117,24 +153,31 @@ func (e *bufferingExporter) bufferRecords(records []otellog.Record) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkReadiness periodically checks if tracing is configured
|
||||
// checkReadiness periodically attempts initialization until successful
|
||||
func (e *bufferingExporter) checkReadiness() {
|
||||
defer close(e.checkerDone)
|
||||
|
||||
ticker := time.NewTicker(1 * time.Second) // Reduced frequency since OTLP handles retries
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// If initialization failed, reset sync.Once to allow retry
|
||||
// The OTLP exporter will handle its own retry logic
|
||||
if e.initErr != nil {
|
||||
e.initOnce = sync.Once{}
|
||||
} else if e.exporter != nil {
|
||||
return // Exporter ready, checker no longer needed
|
||||
// Check if we're fully ready (exporter created AND buffer flushed)
|
||||
e.mu.RLock()
|
||||
fullyReady := e.exporter != nil && e.bufferFlushed
|
||||
e.mu.RUnlock()
|
||||
|
||||
if fullyReady {
|
||||
return // Fully initialized, checker no longer needed
|
||||
}
|
||||
|
||||
// Try to initialize (creates exporter and flushes if token ready)
|
||||
err := e.initialize()
|
||||
e.mu.Lock()
|
||||
e.initErr = err
|
||||
e.mu.Unlock()
|
||||
|
||||
case <-e.stopChecker:
|
||||
return
|
||||
}
|
||||
@@ -180,14 +223,45 @@ func (e *bufferingExporter) Shutdown(ctx context.Context) error {
|
||||
// Stop the readiness checker from continuing
|
||||
close(e.stopChecker)
|
||||
|
||||
// Give one final chance for TLS/tracing to become ready before fully shutting down
|
||||
e.initOnce.Do(func() {
|
||||
e.initErr = e.initialize()
|
||||
})
|
||||
|
||||
// Wait for readiness checker goroutine to complete
|
||||
<-e.checkerDone
|
||||
|
||||
cfg, _, _ := tracerconfig.Get()
|
||||
|
||||
// Check if context is cancelled for best-effort flush
|
||||
forceFlush := ctx.Err() != nil
|
||||
|
||||
// Give one final chance for TLS/tracing to become ready for buffer flushing
|
||||
e.mu.RLock()
|
||||
hasExporter := e.exporter != nil
|
||||
bufferFlushed := e.bufferFlushed
|
||||
e.mu.RUnlock()
|
||||
|
||||
if !hasExporter {
|
||||
err := e.initialize()
|
||||
e.mu.Lock()
|
||||
e.initErr = err
|
||||
hasExporter = e.exporter != nil
|
||||
bufferFlushed = e.bufferFlushed
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// If exporter exists but buffer not flushed, try to flush now
|
||||
if hasExporter && !bufferFlushed {
|
||||
canFlushNow := cfg == nil || e.canFlush(ctx, cfg, forceFlush)
|
||||
if canFlushNow {
|
||||
e.mu.Lock()
|
||||
if !e.bufferFlushed {
|
||||
flushErr := e.flushBuffer(ctx)
|
||||
if flushErr != nil {
|
||||
Setup().Warn("buffer flush failed during shutdown", "error", flushErr)
|
||||
}
|
||||
e.bufferFlushed = true
|
||||
}
|
||||
e.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
|
||||
235
logger/level_test.go
Normal file
235
logger/level_test.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected slog.Level
|
||||
expectError bool
|
||||
}{
|
||||
{"empty string", "", slog.LevelInfo, false},
|
||||
{"DEBUG upper", "DEBUG", slog.LevelDebug, false},
|
||||
{"debug lower", "debug", slog.LevelDebug, false},
|
||||
{"INFO upper", "INFO", slog.LevelInfo, false},
|
||||
{"info lower", "info", slog.LevelInfo, false},
|
||||
{"WARN upper", "WARN", slog.LevelWarn, false},
|
||||
{"warn lower", "warn", slog.LevelWarn, false},
|
||||
{"ERROR upper", "ERROR", slog.LevelError, false},
|
||||
{"error lower", "error", slog.LevelError, false},
|
||||
{"invalid level", "invalid", slog.LevelInfo, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
level, err := ParseLevel(tt.input)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error for input %q, got nil", tt.input)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error for input %q: %v", tt.input, err)
|
||||
}
|
||||
if level != tt.expected {
|
||||
t.Errorf("expected level %v for input %q, got %v", tt.expected, tt.input, level)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLevel(t *testing.T) {
|
||||
// Store original level to restore later
|
||||
originalLevel := Level.Level()
|
||||
defer Level.Set(originalLevel)
|
||||
|
||||
SetLevel(slog.LevelDebug)
|
||||
if Level.Level() != slog.LevelDebug {
|
||||
t.Errorf("expected Level to be Debug, got %v", Level.Level())
|
||||
}
|
||||
|
||||
SetLevel(slog.LevelError)
|
||||
if Level.Level() != slog.LevelError {
|
||||
t.Errorf("expected Level to be Error, got %v", Level.Level())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetOTLPLevel(t *testing.T) {
|
||||
// Store original level to restore later
|
||||
originalLevel := OTLPLevel.Level()
|
||||
defer OTLPLevel.Set(originalLevel)
|
||||
|
||||
SetOTLPLevel(slog.LevelWarn)
|
||||
if OTLPLevel.Level() != slog.LevelWarn {
|
||||
t.Errorf("expected OTLPLevel to be Warn, got %v", OTLPLevel.Level())
|
||||
}
|
||||
|
||||
SetOTLPLevel(slog.LevelDebug)
|
||||
if OTLPLevel.Level() != slog.LevelDebug {
|
||||
t.Errorf("expected OTLPLevel to be Debug, got %v", OTLPLevel.Level())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOTLPLevelHandler(t *testing.T) {
|
||||
// Create a mock handler that counts calls
|
||||
callCount := 0
|
||||
mockHandler := &mockHandler{
|
||||
handleFunc: func(ctx context.Context, r slog.Record) error {
|
||||
callCount++
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Set OTLP level to Warn
|
||||
originalLevel := OTLPLevel.Level()
|
||||
defer OTLPLevel.Set(originalLevel)
|
||||
OTLPLevel.Set(slog.LevelWarn)
|
||||
|
||||
// Create OTLP level handler
|
||||
handler := newOTLPLevelHandler(mockHandler)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test that Debug and Info are filtered out
|
||||
if handler.Enabled(ctx, slog.LevelDebug) {
|
||||
t.Error("Debug level should be disabled when OTLP level is Warn")
|
||||
}
|
||||
if handler.Enabled(ctx, slog.LevelInfo) {
|
||||
t.Error("Info level should be disabled when OTLP level is Warn")
|
||||
}
|
||||
|
||||
// Test that Warn and Error are enabled
|
||||
if !handler.Enabled(ctx, slog.LevelWarn) {
|
||||
t.Error("Warn level should be enabled when OTLP level is Warn")
|
||||
}
|
||||
if !handler.Enabled(ctx, slog.LevelError) {
|
||||
t.Error("Error level should be enabled when OTLP level is Warn")
|
||||
}
|
||||
|
||||
// Test that Handle respects level filtering
|
||||
now := time.Now()
|
||||
debugRecord := slog.NewRecord(now, slog.LevelDebug, "debug message", 0)
|
||||
warnRecord := slog.NewRecord(now, slog.LevelWarn, "warn message", 0)
|
||||
|
||||
handler.Handle(ctx, debugRecord)
|
||||
if callCount != 0 {
|
||||
t.Error("Debug record should not be passed to underlying handler")
|
||||
}
|
||||
|
||||
handler.Handle(ctx, warnRecord)
|
||||
if callCount != 1 {
|
||||
t.Error("Warn record should be passed to underlying handler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentVariables(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envVar string
|
||||
envValue string
|
||||
configPrefix string
|
||||
testFunc func(t *testing.T)
|
||||
}{
|
||||
{
|
||||
name: "LOG_LEVEL sets stderr level",
|
||||
envVar: "LOG_LEVEL",
|
||||
envValue: "ERROR",
|
||||
testFunc: func(t *testing.T) {
|
||||
// Reset the setup state
|
||||
resetLoggerSetup()
|
||||
|
||||
// Call setupStdErrHandler which should read the env var
|
||||
handler := setupStdErrHandler()
|
||||
if handler == nil {
|
||||
t.Fatal("setupStdErrHandler returned nil")
|
||||
}
|
||||
|
||||
if Level.Level() != slog.LevelError {
|
||||
t.Errorf("expected Level to be Error after setting LOG_LEVEL=ERROR, got %v", Level.Level())
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Prefixed LOG_LEVEL",
|
||||
envVar: "TEST_LOG_LEVEL",
|
||||
envValue: "DEBUG",
|
||||
configPrefix: "TEST",
|
||||
testFunc: func(t *testing.T) {
|
||||
ConfigPrefix = "TEST"
|
||||
defer func() { ConfigPrefix = "" }()
|
||||
|
||||
resetLoggerSetup()
|
||||
handler := setupStdErrHandler()
|
||||
if handler == nil {
|
||||
t.Fatal("setupStdErrHandler returned nil")
|
||||
}
|
||||
|
||||
if Level.Level() != slog.LevelDebug {
|
||||
t.Errorf("expected Level to be Debug after setting TEST_LOG_LEVEL=DEBUG, got %v", Level.Level())
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Store original env value and level
|
||||
originalEnv := os.Getenv(tt.envVar)
|
||||
originalLevel := Level.Level()
|
||||
defer func() {
|
||||
os.Setenv(tt.envVar, originalEnv)
|
||||
Level.Set(originalLevel)
|
||||
}()
|
||||
|
||||
// Set test environment variable
|
||||
os.Setenv(tt.envVar, tt.envValue)
|
||||
|
||||
// Run the test
|
||||
tt.testFunc(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mockHandler is a simple mock implementation of slog.Handler for testing
|
||||
type mockHandler struct {
|
||||
handleFunc func(ctx context.Context, r slog.Record) error
|
||||
}
|
||||
|
||||
func (m *mockHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *mockHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
if m.handleFunc != nil {
|
||||
return m.handleFunc(ctx, r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockHandler) WithGroup(name string) slog.Handler {
|
||||
return m
|
||||
}
|
||||
|
||||
// resetLoggerSetup resets the sync.Once instances for testing
|
||||
func resetLoggerSetup() {
|
||||
// Reset package-level variables
|
||||
textLogger = nil
|
||||
otlpLogger = nil
|
||||
multiLogger = nil
|
||||
|
||||
// Note: We can't easily reset sync.Once instances in tests,
|
||||
// but for the specific test we're doing (environment variable parsing)
|
||||
// we can test the setupStdErrHandler function directly
|
||||
}
|
||||
@@ -18,12 +18,15 @@
|
||||
// - Context propagation for request-scoped logging
|
||||
//
|
||||
// Environment variables:
|
||||
// - DEBUG: Enable debug level logging (configurable prefix via ConfigPrefix)
|
||||
// - LOG_LEVEL: Set stderr log level (DEBUG, INFO, WARN, ERROR) (configurable prefix via ConfigPrefix)
|
||||
// - OTLP_LOG_LEVEL: Set OTLP log level independently (configurable prefix via ConfigPrefix)
|
||||
// - DEBUG: Enable debug level logging for backward compatibility (configurable prefix via ConfigPrefix)
|
||||
// - INVOCATION_ID: Systemd detection for timestamp handling
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -43,6 +46,16 @@ import (
|
||||
// This enables multiple services to have independent logging configuration.
|
||||
var ConfigPrefix = ""
|
||||
|
||||
var (
|
||||
// Level controls the log level for the default stderr logger.
|
||||
// Can be changed at runtime to adjust logging verbosity.
|
||||
Level = new(slog.LevelVar) // Info by default
|
||||
|
||||
// OTLPLevel controls the log level for OTLP output.
|
||||
// Can be changed independently from the stderr logger level.
|
||||
OTLPLevel = new(slog.LevelVar) // Info by default
|
||||
)
|
||||
|
||||
var (
|
||||
textLogger *slog.Logger
|
||||
otlpLogger *slog.Logger
|
||||
@@ -56,21 +69,64 @@ var (
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
// SetLevel sets the log level for the default stderr logger.
|
||||
// This affects the primary application logger returned by Setup().
|
||||
func SetLevel(level slog.Level) {
|
||||
Level.Set(level)
|
||||
}
|
||||
|
||||
// SetOTLPLevel sets the log level for OTLP output.
|
||||
// This affects the logger returned by SetupOLTP() and the OTLP portion of SetupMultiLogger().
|
||||
func SetOTLPLevel(level slog.Level) {
|
||||
OTLPLevel.Set(level)
|
||||
}
|
||||
|
||||
// ParseLevel converts a string log level to slog.Level.
|
||||
// Supported levels: "DEBUG", "INFO", "WARN", "ERROR" (case insensitive).
|
||||
// Returns an error for unrecognized level strings.
|
||||
func ParseLevel(level string) (slog.Level, error) {
|
||||
switch {
|
||||
case level == "":
|
||||
return slog.LevelInfo, nil
|
||||
case level == "DEBUG" || level == "debug":
|
||||
return slog.LevelDebug, nil
|
||||
case level == "INFO" || level == "info":
|
||||
return slog.LevelInfo, nil
|
||||
case level == "WARN" || level == "warn":
|
||||
return slog.LevelWarn, nil
|
||||
case level == "ERROR" || level == "error":
|
||||
return slog.LevelError, nil
|
||||
default:
|
||||
return slog.LevelInfo, fmt.Errorf("unknown log level: %s", level)
|
||||
}
|
||||
}
|
||||
|
||||
func setupStdErrHandler() slog.Handler {
|
||||
programLevel := new(slog.LevelVar) // Info by default
|
||||
|
||||
envVar := "DEBUG"
|
||||
// Parse LOG_LEVEL environment variable
|
||||
logLevelVar := "LOG_LEVEL"
|
||||
if len(ConfigPrefix) > 0 {
|
||||
envVar = ConfigPrefix + "_" + envVar
|
||||
logLevelVar = ConfigPrefix + "_" + logLevelVar
|
||||
}
|
||||
|
||||
if opt := os.Getenv(envVar); len(opt) > 0 {
|
||||
if levelStr := os.Getenv(logLevelVar); levelStr != "" {
|
||||
if level, err := ParseLevel(levelStr); err == nil {
|
||||
Level.Set(level)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintain backward compatibility with DEBUG environment variable
|
||||
debugVar := "DEBUG"
|
||||
if len(ConfigPrefix) > 0 {
|
||||
debugVar = ConfigPrefix + "_" + debugVar
|
||||
}
|
||||
|
||||
if opt := os.Getenv(debugVar); len(opt) > 0 {
|
||||
if debug, _ := strconv.ParseBool(opt); debug {
|
||||
programLevel.Set(slog.LevelDebug)
|
||||
Level.Set(slog.LevelDebug)
|
||||
}
|
||||
}
|
||||
|
||||
logOptions := &slog.HandlerOptions{Level: programLevel}
|
||||
logOptions := &slog.HandlerOptions{Level: Level}
|
||||
|
||||
if len(os.Getenv("INVOCATION_ID")) > 0 {
|
||||
// don't add timestamps when running under systemd
|
||||
@@ -88,6 +144,18 @@ func setupStdErrHandler() slog.Handler {
|
||||
|
||||
func setupOtlpLogger() *slog.Logger {
|
||||
setupOtlp.Do(func() {
|
||||
// Parse OTLP_LOG_LEVEL environment variable
|
||||
otlpLevelVar := "OTLP_LOG_LEVEL"
|
||||
if len(ConfigPrefix) > 0 {
|
||||
otlpLevelVar = ConfigPrefix + "_" + otlpLevelVar
|
||||
}
|
||||
|
||||
if levelStr := os.Getenv(otlpLevelVar); levelStr != "" {
|
||||
if level, err := ParseLevel(levelStr); err == nil {
|
||||
OTLPLevel.Set(level)
|
||||
}
|
||||
}
|
||||
|
||||
// Create our buffering exporter
|
||||
// It will buffer until tracing is configured
|
||||
bufferingExp := newBufferingExporter()
|
||||
@@ -107,8 +175,9 @@ func setupOtlpLogger() *slog.Logger {
|
||||
// Set global provider
|
||||
global.SetLoggerProvider(provider)
|
||||
|
||||
// Create slog handler
|
||||
handler := newLogFmtHandler(otelslog.NewHandler("common"))
|
||||
// Create slog handler with level control
|
||||
baseHandler := newLogFmtHandler(otelslog.NewHandler("common"))
|
||||
handler := newOTLPLevelHandler(baseHandler)
|
||||
otlpLogger = slog.New(handler)
|
||||
})
|
||||
return otlpLogger
|
||||
|
||||
48
logger/otlp_handler.go
Normal file
48
logger/otlp_handler.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// otlpLevelHandler is a wrapper that enforces level checking for OTLP handlers.
|
||||
// This allows independent level control for OTLP output separate from stderr logging.
|
||||
type otlpLevelHandler struct {
|
||||
next slog.Handler
|
||||
}
|
||||
|
||||
// newOTLPLevelHandler creates a new OTLP level wrapper handler.
|
||||
func newOTLPLevelHandler(next slog.Handler) slog.Handler {
|
||||
return &otlpLevelHandler{
|
||||
next: next,
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled checks if the log level should be processed by the OTLP handler.
|
||||
// It uses the OTLPLevel variable to determine if the record should be processed.
|
||||
func (h *otlpLevelHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return level >= OTLPLevel.Level()
|
||||
}
|
||||
|
||||
// Handle processes the log record if the level is enabled.
|
||||
// If disabled by level checking, the record is silently dropped.
|
||||
func (h *otlpLevelHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
if !h.Enabled(ctx, r.Level) {
|
||||
return nil
|
||||
}
|
||||
return h.next.Handle(ctx, r)
|
||||
}
|
||||
|
||||
// WithAttrs returns a new handler with the specified attributes added.
|
||||
func (h *otlpLevelHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &otlpLevelHandler{
|
||||
next: h.next.WithAttrs(attrs),
|
||||
}
|
||||
}
|
||||
|
||||
// WithGroup returns a new handler with the specified group name.
|
||||
func (h *otlpLevelHandler) WithGroup(name string) slog.Handler {
|
||||
return &otlpLevelHandler{
|
||||
next: h.next.WithGroup(name),
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,24 @@
|
||||
// This package implements a dedicated metrics server that exposes application metrics
|
||||
// via HTTP. It uses a custom Prometheus registry to avoid conflicts with other metric
|
||||
// collectors and provides graceful shutdown capabilities.
|
||||
//
|
||||
// # Usage
|
||||
//
|
||||
// Create a new metrics server and register your metrics with its Registry():
|
||||
//
|
||||
// m := metricsserver.New()
|
||||
// myCounter := prometheus.NewCounter(...)
|
||||
// m.Registry().MustRegister(myCounter)
|
||||
//
|
||||
// When you need a Gatherer (for example, to pass to other libraries), use the Gatherer() method
|
||||
// instead of prometheus.DefaultGatherer to ensure your custom metrics are collected:
|
||||
//
|
||||
// gatherer := m.Gatherer() // Returns the custom registry as a Gatherer
|
||||
//
|
||||
// To use the default Prometheus gatherer alongside your custom registry:
|
||||
//
|
||||
// m := metricsserver.NewWithDefaultGatherer()
|
||||
// m.Gatherer() // Returns prometheus.DefaultGatherer
|
||||
package metricsserver
|
||||
|
||||
import (
|
||||
@@ -22,14 +40,31 @@ import (
|
||||
// It isolates application metrics from the default global registry.
|
||||
type Metrics struct {
|
||||
r *prometheus.Registry
|
||||
useDefaultGatherer bool
|
||||
}
|
||||
|
||||
// New creates a new Metrics instance with a custom Prometheus registry.
|
||||
// Use this when you want isolated metrics that don't interfere with the default registry.
|
||||
func New() *Metrics {
|
||||
r := prometheus.NewRegistry()
|
||||
|
||||
m := &Metrics{
|
||||
r: r,
|
||||
useDefaultGatherer: false,
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// NewWithDefaultGatherer creates a new Metrics instance that uses the default Prometheus gatherer.
|
||||
// This is useful when you want to expose metrics from the default registry alongside your custom metrics.
|
||||
// The custom registry will still be available via Registry() but Gatherer() will return prometheus.DefaultGatherer.
|
||||
func NewWithDefaultGatherer() *Metrics {
|
||||
r := prometheus.NewRegistry()
|
||||
|
||||
m := &Metrics{
|
||||
r: r,
|
||||
useDefaultGatherer: true,
|
||||
}
|
||||
|
||||
return m
|
||||
@@ -41,6 +76,23 @@ func (m *Metrics) Registry() *prometheus.Registry {
|
||||
return m.r
|
||||
}
|
||||
|
||||
// Gatherer returns the Prometheus gatherer to use for collecting metrics.
|
||||
// This returns the custom registry's Gatherer by default, ensuring your registered
|
||||
// metrics are collected. If the instance was created with NewWithDefaultGatherer(),
|
||||
// this returns prometheus.DefaultGatherer instead.
|
||||
//
|
||||
// Use this method when you need a prometheus.Gatherer interface, for example when
|
||||
// configuring other libraries that need to collect metrics.
|
||||
//
|
||||
// IMPORTANT: Do not use prometheus.DefaultGatherer directly if you want to collect
|
||||
// metrics registered with this instance's Registry(). Always use this Gatherer() method.
|
||||
func (m *Metrics) Gatherer() prometheus.Gatherer {
|
||||
if m.useDefaultGatherer {
|
||||
return prometheus.DefaultGatherer
|
||||
}
|
||||
return m.r
|
||||
}
|
||||
|
||||
// Handler returns an HTTP handler for the /metrics endpoint with OpenMetrics support.
|
||||
func (m *Metrics) Handler() http.Handler {
|
||||
log := logger.NewStdLog("prom http", false, nil)
|
||||
|
||||
@@ -67,6 +67,47 @@ func TestRegistry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatherer(t *testing.T) {
|
||||
metrics := New()
|
||||
|
||||
gatherer := metrics.Gatherer()
|
||||
if gatherer == nil {
|
||||
t.Fatal("Gatherer() returned nil")
|
||||
}
|
||||
|
||||
// Register a test metric
|
||||
counter := prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "test_gatherer_counter",
|
||||
Help: "A test counter for gatherer",
|
||||
})
|
||||
|
||||
metrics.Registry().MustRegister(counter)
|
||||
counter.Inc()
|
||||
|
||||
// Test that the gatherer collects our custom metric
|
||||
metricFamilies, err := gatherer.Gather()
|
||||
if err != nil {
|
||||
t.Errorf("failed to gather metrics: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, mf := range metricFamilies {
|
||||
if mf.GetName() == "test_gatherer_counter" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("registered metric not found via Gatherer()")
|
||||
}
|
||||
|
||||
// Verify gatherer is the same as registry
|
||||
if gatherer != metrics.r {
|
||||
t.Error("Gatherer() should return the same object as the registry for custom registry mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler(t *testing.T) {
|
||||
metrics := New()
|
||||
|
||||
@@ -212,6 +253,45 @@ func TestListenAndServeContextCancellation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithDefaultGatherer(t *testing.T) {
|
||||
metrics := NewWithDefaultGatherer()
|
||||
|
||||
if metrics == nil {
|
||||
t.Fatal("NewWithDefaultGatherer returned nil")
|
||||
}
|
||||
|
||||
if !metrics.useDefaultGatherer {
|
||||
t.Error("useDefaultGatherer should be true")
|
||||
}
|
||||
|
||||
gatherer := metrics.Gatherer()
|
||||
if gatherer == nil {
|
||||
t.Fatal("Gatherer() returned nil")
|
||||
}
|
||||
|
||||
// Verify it returns the default gatherer
|
||||
if gatherer != prometheus.DefaultGatherer {
|
||||
t.Error("Gatherer() should return prometheus.DefaultGatherer when useDefaultGatherer is true")
|
||||
}
|
||||
|
||||
// Verify the custom registry is still available and separate
|
||||
if metrics.Registry() == nil {
|
||||
t.Error("Registry() should still return a custom registry")
|
||||
}
|
||||
|
||||
// Test that registering in custom registry doesn't affect default gatherer check
|
||||
counter := prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "test_default_gatherer_counter",
|
||||
Help: "A test counter",
|
||||
})
|
||||
metrics.Registry().MustRegister(counter)
|
||||
|
||||
// The gatherer should still be the default one, not our custom registry
|
||||
if metrics.Gatherer() != prometheus.DefaultGatherer {
|
||||
t.Error("Gatherer() should continue to return prometheus.DefaultGatherer")
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark the metrics handler response time
|
||||
func BenchmarkMetricsHandler(b *testing.B) {
|
||||
metrics := New()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
go install github.com/goreleaser/goreleaser/v2@v2.11.0
|
||||
go install github.com/goreleaser/goreleaser/v2@v2.14.1
|
||||
|
||||
if [ ! -z "${harbor_username:-}" ]; then
|
||||
DOCKER_FILE=~/.docker/config.json
|
||||
@@ -13,11 +13,11 @@ if [ ! -z "${harbor_username:-}" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
DRONE_TAG=${DRONE_TAG-""}
|
||||
CI_TAG=${CI_COMMIT_TAG:-${DRONE_TAG:-""}}
|
||||
|
||||
is_snapshot=""
|
||||
|
||||
if [ -z "$DRONE_TAG" ]; then
|
||||
if [ -z "$CI_TAG" ]; then
|
||||
is_snapshot="--snapshot"
|
||||
fi
|
||||
|
||||
|
||||
@@ -112,6 +112,10 @@ func Start(ctx context.Context, spanName string, opts ...trace.SpanStartOption)
|
||||
// This maintains backward compatibility for existing code.
|
||||
type GetClientCertificate = tracerconfig.GetClientCertificate
|
||||
|
||||
// BearerTokenFunc is an alias for the type defined in tracerconfig.
|
||||
// It retrieves a bearer token for OTLP authentication.
|
||||
type BearerTokenFunc = tracerconfig.BearerTokenFunc
|
||||
|
||||
// TracerConfig provides configuration options for OpenTelemetry tracing setup.
|
||||
// It supplements standard OpenTelemetry environment variables with additional
|
||||
// NTP Pool-specific configuration including TLS settings for secure OTLP export.
|
||||
@@ -123,6 +127,7 @@ type TracerConfig struct {
|
||||
|
||||
CertificateProvider GetClientCertificate // Client certificate provider for mutual TLS
|
||||
RootCAs *x509.CertPool // CA certificate pool for server verification
|
||||
BearerTokenFunc BearerTokenFunc // Token provider for bearer authentication
|
||||
}
|
||||
|
||||
// InitTracer initializes the OpenTelemetry SDK with the provided configuration.
|
||||
@@ -160,6 +165,7 @@ func SetupSDK(ctx context.Context, cfg *TracerConfig) (shutdown TpShutdownFunc,
|
||||
EndpointURL: cfg.EndpointURL,
|
||||
CertificateProvider: cfg.CertificateProvider,
|
||||
RootCAs: cfg.RootCAs,
|
||||
BearerTokenFunc: cfg.BearerTokenFunc,
|
||||
}
|
||||
tracerconfig.Store(ctx, bridgeConfig, createOTLPLogExporter, createOTLPMetricExporter, createOTLPTraceExporter)
|
||||
|
||||
|
||||
@@ -90,10 +90,13 @@ func init() {
|
||||
VERSION = "dev-snapshot"
|
||||
} else {
|
||||
if !strings.HasPrefix(VERSION, "v") {
|
||||
VERSION = "v" + VERSION
|
||||
vVersion := "v" + VERSION
|
||||
if semver.IsValid(vVersion) {
|
||||
VERSION = vVersion
|
||||
}
|
||||
if !semver.IsValid(VERSION) {
|
||||
slog.Default().Warn("invalid version number", "version", VERSION)
|
||||
}
|
||||
if !semver.IsValid(VERSION) && VERSION != "dev-snapshot" {
|
||||
slog.Default().Info("non-semver version", "version", VERSION)
|
||||
}
|
||||
}
|
||||
if bi, ok := debug.ReadBuildInfo(); ok {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
func TestCheckVersion(t *testing.T) {
|
||||
@@ -48,9 +49,12 @@ func TestVersionInfo(t *testing.T) {
|
||||
t.Error("VersionInfo().Version should not be empty")
|
||||
}
|
||||
|
||||
// Version should start with "v" or be "dev-snapshot"
|
||||
// Version should start with "v", be "dev-snapshot", or be a non-semver string (e.g. branch name)
|
||||
if !strings.HasPrefix(info.Version, "v") && info.Version != "dev-snapshot" {
|
||||
t.Errorf("Version should start with 'v' or be 'dev-snapshot', got: %s", info.Version)
|
||||
// Non-semver versions like branch names ("main") are acceptable
|
||||
if semver.IsValid("v" + info.Version) {
|
||||
t.Errorf("Semver-like version should start with 'v', got: %s", info.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// GitRevShort should be <= 7 characters if set
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Package fastlyxff provides Fastly CDN IP range management for trusted proxy handling.
|
||||
//
|
||||
// This package parses Fastly's public IP ranges JSON file and generates Echo framework
|
||||
// trust options for proper client IP extraction from X-Forwarded-For headers.
|
||||
// It's designed specifically for services deployed behind Fastly's CDN that need
|
||||
// to identify real client IPs for logging, rate limiting, and security purposes.
|
||||
// This package parses Fastly's public IP ranges JSON file and provides middleware
|
||||
// for both Echo framework and standard net/http for proper client IP extraction
|
||||
// from X-Forwarded-For headers. It's designed specifically for services deployed
|
||||
// behind Fastly's CDN that need to identify real client IPs for logging, rate
|
||||
// limiting, and security purposes.
|
||||
//
|
||||
// Fastly publishes their edge server IP ranges in a JSON format that this package
|
||||
// consumes to automatically configure trusted proxy ranges. This ensures that
|
||||
@@ -14,8 +15,55 @@
|
||||
// - Automatic parsing of Fastly's IP ranges JSON format
|
||||
// - Support for both IPv4 and IPv6 address ranges
|
||||
// - Echo framework integration via TrustOption generation
|
||||
// - Standard net/http middleware support
|
||||
// - CIDR notation parsing and validation
|
||||
//
|
||||
// # Echo Framework Usage
|
||||
//
|
||||
// fastlyRanges, err := fastlyxff.New("fastly.json")
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// options, err := fastlyRanges.EchoTrustOption()
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// e.IPExtractor = echo.ExtractIPFromXFFHeader(options...)
|
||||
//
|
||||
// # Net/HTTP Usage
|
||||
//
|
||||
// fastlyRanges, err := fastlyxff.New("fastly.json")
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// middleware := fastlyRanges.HTTPMiddleware()
|
||||
//
|
||||
// handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// // Both methods work - middleware updates r.RemoteAddr (with port 0) and stores in context
|
||||
// realIP := fastlyxff.GetRealIP(r) // Preferred method
|
||||
// // OR: host, _, _ := net.SplitHostPort(r.RemoteAddr) // Direct access (port will be "0")
|
||||
// fmt.Fprintf(w, "Real IP: %s\n", realIP)
|
||||
// })
|
||||
//
|
||||
// http.ListenAndServe(":8080", middleware(handler))
|
||||
//
|
||||
// # Net/HTTP with Additional Trusted Ranges
|
||||
//
|
||||
// fastlyRanges, err := fastlyxff.New("fastly.json")
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// // Add custom trusted CIDRs (e.g., internal load balancers)
|
||||
// // Note: For Echo framework, use the ekko package for additional ranges
|
||||
// err = fastlyRanges.AddTrustedCIDR("10.0.0.0/8")
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// middleware := fastlyRanges.HTTPMiddleware()
|
||||
// handler := middleware(yourHandler)
|
||||
//
|
||||
// The JSON file typically contains IP ranges in this format:
|
||||
//
|
||||
// {
|
||||
@@ -25,29 +73,36 @@
|
||||
package fastlyxff
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// FastlyXFF represents Fastly's published IP ranges for their CDN edge servers.
|
||||
// This structure matches the JSON format provided by Fastly for their public IP ranges.
|
||||
// It contains separate lists for IPv4 and IPv6 CIDR ranges.
|
||||
// It contains separate lists for IPv4 and IPv6 CIDR ranges, plus additional trusted CIDRs.
|
||||
type FastlyXFF struct {
|
||||
IPv4 []string `json:"addresses"` // IPv4 CIDR ranges (e.g., "23.235.32.0/20")
|
||||
IPv6 []string `json:"ipv6_addresses"` // IPv6 CIDR ranges (e.g., "2a04:4e40::/32")
|
||||
extraCIDRs []string // Additional trusted CIDRs added via AddTrustedCIDR
|
||||
}
|
||||
|
||||
// TrustedNets holds parsed network prefixes for efficient IP range checking.
|
||||
// This type is currently unused but reserved for future optimizations
|
||||
// where frequent IP range lookups might benefit from pre-parsed prefixes.
|
||||
type TrustedNets struct {
|
||||
prefixes []netip.Prefix // Parsed network prefixes for efficient lookups
|
||||
}
|
||||
|
||||
// contextKey is used for storing the real client IP in request context
|
||||
type contextKey string
|
||||
|
||||
const realIPKey contextKey = "fastly-real-ip"
|
||||
|
||||
// New loads and parses Fastly IP ranges from a JSON file.
|
||||
// The file should contain Fastly's published IP ranges in their standard JSON format.
|
||||
//
|
||||
@@ -100,3 +155,116 @@ func (xff *FastlyXFF) EchoTrustOption() ([]echo.TrustOption, error) {
|
||||
|
||||
return ranges, nil
|
||||
}
|
||||
|
||||
// AddTrustedCIDR adds an additional CIDR to the list of trusted proxies.
|
||||
// This allows trusting proxies beyond Fastly's published ranges.
|
||||
// The cidr parameter must be a valid CIDR notation (e.g., "10.0.0.0/8", "192.168.1.0/24").
|
||||
// Returns an error if the CIDR format is invalid.
|
||||
func (xff *FastlyXFF) AddTrustedCIDR(cidr string) error {
|
||||
// Validate CIDR format
|
||||
_, _, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add to extra CIDRs
|
||||
xff.extraCIDRs = append(xff.extraCIDRs, cidr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTrustedProxy checks if the given IP address belongs to Fastly's trusted IP ranges
|
||||
// or any additional CIDRs added via AddTrustedCIDR.
|
||||
func (xff *FastlyXFF) isTrustedProxy(ip string) bool {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check all IPv4 and IPv6 ranges (Fastly + additional)
|
||||
allRanges := append(append(xff.IPv4, xff.IPv6...), xff.extraCIDRs...)
|
||||
for _, s := range allRanges {
|
||||
_, cidr, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if cidr.Contains(net.IP(addr.AsSlice())) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractRealIP extracts the real client IP from X-Forwarded-For header.
|
||||
// It returns the rightmost IP that is not from a trusted Fastly proxy.
|
||||
func (xff *FastlyXFF) extractRealIP(r *http.Request) string {
|
||||
// Get the immediate peer IP
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
|
||||
// If the immediate peer is not a trusted Fastly proxy, return it
|
||||
if !xff.isTrustedProxy(host) {
|
||||
return host
|
||||
}
|
||||
|
||||
// Check X-Forwarded-For header
|
||||
xff_header := r.Header.Get("X-Forwarded-For")
|
||||
if xff_header == "" {
|
||||
return host
|
||||
}
|
||||
|
||||
// Parse comma-separated IP list
|
||||
ips := strings.Split(xff_header, ",")
|
||||
if len(ips) == 0 {
|
||||
return host
|
||||
}
|
||||
|
||||
// Find the leftmost IP that is not from a trusted proxy
|
||||
// This represents the original client IP
|
||||
for i := 0; i < len(ips); i++ {
|
||||
ip := strings.TrimSpace(ips[i])
|
||||
if ip != "" && !xff.isTrustedProxy(ip) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the immediate peer
|
||||
return host
|
||||
}
|
||||
|
||||
// HTTPMiddleware returns a net/http middleware that extracts real client IP
|
||||
// from X-Forwarded-For headers when the request comes from trusted Fastly proxies.
|
||||
// The real IP is stored in the request context and also updates r.RemoteAddr
|
||||
// with port 0 (since the original port is from the proxy, not the real client).
|
||||
func (xff *FastlyXFF) HTTPMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
realIP := xff.extractRealIP(r)
|
||||
|
||||
// Store in context for GetRealIP function
|
||||
ctx := context.WithValue(r.Context(), realIPKey, realIP)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Update RemoteAddr to be consistent with extracted IP
|
||||
// Use port 0 since the original port is from the proxy, not the real client
|
||||
r.RemoteAddr = net.JoinHostPort(realIP, "0")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetRealIP retrieves the real client IP from the request context.
|
||||
// This should be used after the HTTPMiddleware has processed the request.
|
||||
// Returns the remote address if no real IP was extracted.
|
||||
func GetRealIP(r *http.Request) string {
|
||||
if ip, ok := r.Context().Value(realIPKey).(string); ok {
|
||||
return ip
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package fastlyxff
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFastlyIPRanges(t *testing.T) {
|
||||
fastlyxff, err := New("fastly.json")
|
||||
@@ -18,3 +23,334 @@ func TestFastlyIPRanges(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPMiddleware(t *testing.T) {
|
||||
// Create a test FastlyXFF instance with known IP ranges
|
||||
xff := &FastlyXFF{
|
||||
IPv4: []string{"192.0.2.0/24", "203.0.113.0/24"},
|
||||
IPv6: []string{"2001:db8::/32"},
|
||||
}
|
||||
|
||||
middleware := xff.HTTPMiddleware()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
xForwardedFor string
|
||||
expectedRealIP string
|
||||
}{
|
||||
{
|
||||
name: "direct connection",
|
||||
remoteAddr: "198.51.100.1:12345",
|
||||
xForwardedFor: "",
|
||||
expectedRealIP: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
name: "trusted proxy with XFF",
|
||||
remoteAddr: "192.0.2.1:80",
|
||||
xForwardedFor: "198.51.100.1",
|
||||
expectedRealIP: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
name: "trusted proxy with multiple XFF",
|
||||
remoteAddr: "192.0.2.1:80",
|
||||
xForwardedFor: "198.51.100.1, 203.0.113.1",
|
||||
expectedRealIP: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
name: "untrusted proxy ignored",
|
||||
remoteAddr: "198.51.100.2:80",
|
||||
xForwardedFor: "10.0.0.1",
|
||||
expectedRealIP: "198.51.100.2",
|
||||
},
|
||||
{
|
||||
name: "IPv6 trusted proxy",
|
||||
remoteAddr: "[2001:db8::1]:80",
|
||||
xForwardedFor: "198.51.100.1",
|
||||
expectedRealIP: "198.51.100.1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create test handler that captures both GetRealIP and r.RemoteAddr
|
||||
var capturedRealIP, capturedRemoteAddr string
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedRealIP = GetRealIP(r)
|
||||
capturedRemoteAddr = r.RemoteAddr
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Create request with middleware
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = tt.remoteAddr
|
||||
if tt.xForwardedFor != "" {
|
||||
req.Header.Set("X-Forwarded-For", tt.xForwardedFor)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
middleware(handler).ServeHTTP(rr, req)
|
||||
|
||||
// Test GetRealIP function
|
||||
if capturedRealIP != tt.expectedRealIP {
|
||||
t.Errorf("GetRealIP: expected %s, got %s", tt.expectedRealIP, capturedRealIP)
|
||||
}
|
||||
|
||||
// Test that r.RemoteAddr is updated with real IP and port 0
|
||||
// (since the original port is from the proxy, not the real client)
|
||||
expectedRemoteAddr := net.JoinHostPort(tt.expectedRealIP, "0")
|
||||
if capturedRemoteAddr != expectedRemoteAddr {
|
||||
t.Errorf("RemoteAddr: expected %s, got %s", expectedRemoteAddr, capturedRemoteAddr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTrustedProxy(t *testing.T) {
|
||||
xff := &FastlyXFF{
|
||||
IPv4: []string{"192.0.2.0/24", "203.0.113.0/24"},
|
||||
IPv6: []string{"2001:db8::/32"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{"192.0.2.1", true},
|
||||
{"192.0.2.255", true},
|
||||
{"203.0.113.1", true},
|
||||
{"192.0.3.1", false},
|
||||
{"198.51.100.1", false},
|
||||
{"2001:db8::1", true},
|
||||
{"2001:db8:ffff::1", true},
|
||||
{"2001:db9::1", false},
|
||||
{"invalid-ip", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.ip, func(t *testing.T) {
|
||||
result := xff.isTrustedProxy(tt.ip)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isTrustedProxy(%s) = %v, expected %v", tt.ip, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRealIP(t *testing.T) {
|
||||
xff := &FastlyXFF{
|
||||
IPv4: []string{"192.0.2.0/24"},
|
||||
IPv6: []string{"2001:db8::/32"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
xForwardedFor string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no XFF header",
|
||||
remoteAddr: "198.51.100.1:12345",
|
||||
xForwardedFor: "",
|
||||
expected: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
name: "trusted proxy with single IP",
|
||||
remoteAddr: "192.0.2.1:80",
|
||||
xForwardedFor: "198.51.100.1",
|
||||
expected: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
name: "trusted proxy with multiple IPs",
|
||||
remoteAddr: "192.0.2.1:80",
|
||||
xForwardedFor: "198.51.100.1, 203.0.113.5",
|
||||
expected: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
name: "untrusted proxy",
|
||||
remoteAddr: "198.51.100.1:80",
|
||||
xForwardedFor: "10.0.0.1",
|
||||
expected: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
name: "empty XFF",
|
||||
remoteAddr: "192.0.2.1:80",
|
||||
xForwardedFor: "",
|
||||
expected: "192.0.2.1",
|
||||
},
|
||||
{
|
||||
name: "malformed remote addr",
|
||||
remoteAddr: "192.0.2.1",
|
||||
xForwardedFor: "198.51.100.1",
|
||||
expected: "198.51.100.1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = tt.remoteAddr
|
||||
if tt.xForwardedFor != "" {
|
||||
req.Header.Set("X-Forwarded-For", tt.xForwardedFor)
|
||||
}
|
||||
|
||||
result := xff.extractRealIP(req)
|
||||
if result != tt.expected {
|
||||
t.Errorf("extractRealIP() = %s, expected %s", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealIPWithoutMiddleware(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = "198.51.100.1:12345"
|
||||
|
||||
realIP := GetRealIP(req)
|
||||
expected := "198.51.100.1"
|
||||
if realIP != expected {
|
||||
t.Errorf("GetRealIP() = %s, expected %s", realIP, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTrustedCIDR(t *testing.T) {
|
||||
xff := &FastlyXFF{
|
||||
IPv4: []string{"192.0.2.0/24"},
|
||||
IPv6: []string{"2001:db8::/32"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cidr string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid IPv4 range", "10.0.0.0/8", false},
|
||||
{"valid IPv6 range", "fc00::/7", false},
|
||||
{"valid single IP", "203.0.113.1/32", false},
|
||||
{"invalid CIDR", "not-a-cidr", true},
|
||||
{"invalid format", "10.0.0.0/99", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := xff.AddTrustedCIDR(tt.cidr)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("AddTrustedCIDR(%s) error = %v, wantErr %v", tt.cidr, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomTrustedCIDRs(t *testing.T) {
|
||||
xff := &FastlyXFF{
|
||||
IPv4: []string{"192.0.2.0/24"},
|
||||
IPv6: []string{"2001:db8::/32"},
|
||||
}
|
||||
|
||||
// Add custom trusted CIDRs
|
||||
err := xff.AddTrustedCIDR("10.0.0.0/8")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add trusted CIDR: %v", err)
|
||||
}
|
||||
|
||||
err = xff.AddTrustedCIDR("172.16.0.0/12")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add trusted CIDR: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
// Original Fastly ranges
|
||||
{"192.0.2.1", true},
|
||||
{"2001:db8::1", true},
|
||||
// Custom CIDRs
|
||||
{"10.1.2.3", true},
|
||||
{"172.16.1.1", true},
|
||||
// Not trusted
|
||||
{"198.51.100.1", false},
|
||||
{"172.15.1.1", false},
|
||||
{"10.0.0.0", true}, // Network address should still match
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.ip, func(t *testing.T) {
|
||||
result := xff.isTrustedProxy(tt.ip)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isTrustedProxy(%s) = %v, expected %v", tt.ip, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPMiddlewareWithCustomCIDRs(t *testing.T) {
|
||||
xff := &FastlyXFF{
|
||||
IPv4: []string{"192.0.2.0/24"},
|
||||
IPv6: []string{"2001:db8::/32"},
|
||||
}
|
||||
|
||||
// Add custom trusted CIDR for internal proxies
|
||||
err := xff.AddTrustedCIDR("10.0.0.0/8")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add trusted CIDR: %v", err)
|
||||
}
|
||||
|
||||
middleware := xff.HTTPMiddleware()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
xForwardedFor string
|
||||
expectedRealIP string
|
||||
}{
|
||||
{
|
||||
name: "custom trusted proxy with XFF",
|
||||
remoteAddr: "10.1.2.3:80",
|
||||
xForwardedFor: "198.51.100.1",
|
||||
expectedRealIP: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
name: "fastly proxy with XFF",
|
||||
remoteAddr: "192.0.2.1:80",
|
||||
xForwardedFor: "198.51.100.1",
|
||||
expectedRealIP: "198.51.100.1",
|
||||
},
|
||||
{
|
||||
name: "untrusted proxy ignored",
|
||||
remoteAddr: "172.16.1.1:80",
|
||||
xForwardedFor: "198.51.100.1",
|
||||
expectedRealIP: "172.16.1.1",
|
||||
},
|
||||
{
|
||||
name: "chain through custom and fastly",
|
||||
remoteAddr: "192.0.2.1:80",
|
||||
xForwardedFor: "198.51.100.1, 10.1.2.3",
|
||||
expectedRealIP: "198.51.100.1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var capturedIP string
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedIP = GetRealIP(r)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = tt.remoteAddr
|
||||
if tt.xForwardedFor != "" {
|
||||
req.Header.Set("X-Forwarded-For", tt.xForwardedFor)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
middleware(handler).ServeHTTP(rr, req)
|
||||
|
||||
if capturedIP != tt.expectedRealIP {
|
||||
t.Errorf("expected real IP %s, got %s", tt.expectedRealIP, capturedIP)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user