14 Commits

Author SHA1 Message Date
c7b8a92a7c build(deps): update Go and module dependencies
Update Go from 1.24 to 1.25, OpenTelemetry from 1.39 to 1.42,
slog-echo from 1.18 to 1.21, echo-contrib, pgx, and other dependencies.
2026-03-21 18:45:43 -07:00
1b9e566892 refactor(xff): split into generic, echo, and fastly packages
Extract generic trusted proxy handling into xff/ (stdlib only),
Echo framework adapter into xff/echo/, and slim xff/fastlyxff/
down to Fastly JSON loading.

Key changes:
- xff/ uses netip.Prefix for efficient IP matching
- Fix XFF extraction to walk right-to-left per MDN spec
- Remove echo dependency from core xff package
- fastlyxff.New() now returns *xff.TrustedProxies
2026-03-21 18:41:28 -07:00
82de580879 feat(ekko): add WithTrustOptions for CDN IP trust configuration
Allow callers to append additional echo.TrustOption values to the
default IP extraction configuration. This enables trusting CDN IP
ranges (e.g. Fastly) when extracting client IPs from X-Forwarded-For.
2026-03-08 18:31:44 -07:00
92b202037a fix(ekko): store service name and add fallback for Server header
The name parameter passed to ekko.New() was never stored on the struct,
causing the HTTP Server header to be malformed (e.g. "/vdev-snapshot+hash"
instead of "warmform/vdev-snapshot+hash").

Store the name in the struct literal and add a fallback that derives the
name from debug.ReadBuildInfo() if an empty string is passed.
2026-03-08 17:41:41 -07:00
af7683da9a fix(version): don't add "v" prefix to non-semver VERSION strings
When VERSION is set to a non-tag value like "main" (from goreleaser or
ldflags), the init() function unconditionally prepended "v", producing
"vmain". Now only add the "v" prefix when doing so produces a valid
semver string, leaving branch names and other non-semver values as-is.
2026-03-08 14:02:45 -07:00
3c801842e4 build(ci): support both Woodpecker and Drone in run-goreleaser
Use CI_COMMIT_TAG with DRONE_TAG fallback so the script
works in both CI systems during migration.
2026-03-07 16:21:20 -08:00
d56e33b171 fix(ekko): include client IP in request logs
Use slogecho.DefaultConfig() instead of a zero-value struct literal
so WithClientIP (and WithRequestID) defaults are inherited.
2026-02-21 01:11:30 -08:00
614cbf8097 feat(pgdb): add PG* environment variable fallback in OpenPool
When no DATABASE_URI or config file is found, fall through to
pgxpool.ParseConfig("") which natively reads standard PG* variables
(PGHOST, PGUSER, PGPASSWORD, PGDATABASE, etc.). This removes
unnecessary ceremony in CI and container environments where PG* vars
are already set.
2026-02-21 00:34:09 -08:00
1df4b0d4b4 feat(tracing): add bearer token authentication for OTLP exporters
Add BearerTokenFunc to support dynamic bearer token authentication
for OTLP exporters. Tokens are injected per-request via gRPC
PerRPCCredentials and HTTP custom RoundTripper.

- Add BearerTokenFunc type and Config field in tracerconfig
- Implement bearerCredentials (gRPC) and bearerRoundTripper (HTTP)
- Wire bearer auth into all exporter creation functions
- Add getHTTPClient helper for DRY HTTP client configuration
- Upgrade OpenTelemetry SDK to v1.39.0 for WithHTTPClient support
2026-01-01 03:39:01 -08:00
d43ff0f2a9 fix(pgdb): align pool defaults with pgxpool
Change MaxConns default from 25 to 4 to match pgxpool defaults.
This ensures consistent behavior between DATABASE_URI and config
file modes, and removes the warning log when using URI mode.

Defaults now reference: https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool#Config
2025-11-30 09:35:55 -08:00
66a72265ce feat(pgdb): export config discovery functions for display purposes
Add FindConfig, ParseURIConfig, and GetConfigFiles as exported functions
to allow callers to retrieve database configuration info without
establishing a connection. Useful for version/startup info display.
2025-11-29 13:04:36 -08:00
94b718a925 fix(database): correct metrics and improve error handling
- Fix metrics double-counting: track deltas for WaitCount/WaitDuration
  instead of adding cumulative values each tick
- Replace fmt.Printf with structured logging in pool monitor
- Add PoolOptions validation (MaxConns > 0, MinConns >= 0)
- Warn when DATABASE_URI overrides non-default PoolOptions
- Improve findAndParseConfig to report all tried files and errors
- Remove dead code in pgdb/config.go (unreachable host default)
- Fix errcheck lint issues for file.Close() calls
- Add context parameter to OpenDBMonitor() (breaking change)
2025-11-29 12:56:49 -08:00
283d3936f6 feat(pgdb): add DATABASE_URI environment variable support
Add support for PostgreSQL connection URIs via DATABASE_URI env var.
When set, it takes precedence over config files and PoolOptions are
ignored (pool settings can be specified in URI query string).
2025-11-29 11:52:45 -08:00
7291f00f48 feat(metricsserver): add Gatherer method
Add explicit Gatherer() method to improve API discoverability
and prevent users from accidentally using prometheus.DefaultGatherer
instead of the custom registry.

Changes:
- Add Gatherer() method returning prometheus.Gatherer interface
- Add NewWithDefaultGatherer() constructor for opt-in default usage
- Update package docs with usage examples
- Add tests for both gatherer modes
2025-10-12 16:13:19 -07:00
28 changed files with 1767 additions and 938 deletions

View File

@@ -50,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{}

View File

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

View File

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

View File

@@ -75,13 +75,17 @@ sslmode: prefer
### 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: 25)
- `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)
@@ -93,7 +97,34 @@ sslmode: 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

View File

@@ -27,15 +27,10 @@ func CreatePoolConfig(cfg *database.PostgresConfig) (*pgxpool.Config, error) {
"require": true, "verify-ca": true, "verify-full": true,
}
if cfg.SSLMode != "" && !validSSLModes[cfg.SSLMode] {
return nil, fmt.Errorf("postgres: invalid sslmode: %s", cfg.SSLMode)
}
// Set defaults
host := cfg.Host
if host == "" {
host = "localhost"
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
@@ -49,7 +44,7 @@ func CreatePoolConfig(cfg *database.PostgresConfig) (*pgxpool.Config, error) {
// 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",
host, port, cfg.User, cfg.Name, sslmode)
cfg.Host, port, cfg.User, cfg.Name, sslmode)
// Parse the connection string
poolConfig, err := pgxpool.ParseConfig(connString)

View File

@@ -2,6 +2,7 @@ package pgdb
import (
"context"
"errors"
"fmt"
"os"
"time"
@@ -11,38 +12,47 @@ import (
"gopkg.in/yaml.v3"
)
// PoolOptions configures pgxpool connection behavior
// 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 (no minimum)
// 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: 25
// 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
// 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
// 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
// HealthCheckPeriod is how often to check connection health.
// Default: 1 minute (matches pgxpool default)
HealthCheckPeriod time.Duration
}
// DefaultPoolOptions returns sensible defaults for pgxpool
// 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(),
ConfigFiles: GetConfigFiles(),
MinConns: 0,
MaxConns: 25,
MaxConns: 4,
MaxConnLifetime: time.Hour,
MaxConnIdleTime: 30 * time.Minute,
HealthCheckPeriod: time.Minute,
@@ -51,25 +61,65 @@ func DefaultPoolOptions() PoolOptions {
// 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) {
// Find and read config file
pgCfg, err := findAndParseConfig(options.ConfigFiles)
if err != nil {
return nil, err
// 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)
}
// Create pool config from PostgreSQL config
poolConfig, err := CreatePoolConfig(pgCfg)
if err != nil {
return nil, err
}
var poolConfig *pgxpool.Config
var err error
// Apply pool-specific settings
poolConfig.MinConns = options.MinConns
poolConfig.MaxConns = options.MaxConns
poolConfig.MaxConnLifetime = options.MaxConnLifetime
poolConfig.MaxConnIdleTime = options.MaxConnIdleTime
poolConfig.HealthCheckPeriod = options.HealthCheckPeriod
// 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)
@@ -79,7 +129,7 @@ func OpenPool(ctx context.Context, options PoolOptions) (*pgxpool.Pool, error) {
// Test the connection
if err := pool.Ping(ctx); err != nil {
pool.Close()
pool.Close() // pgxpool.Pool.Close() doesn't return an error
return nil, fmt.Errorf("failed to ping database: %w", err)
}
@@ -94,39 +144,54 @@ func OpenPoolWithConfigFile(ctx context.Context, configFile string) (*pgxpool.Po
return OpenPool(ctx, options)
}
// findAndParseConfig searches for and parses the first existing config file
func findAndParseConfig(configFiles []string) (*database.PostgresConfig, error) {
var firstErr error
// 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 {
if firstErr == nil {
firstErr = err
}
errs = append(errs, fmt.Errorf("%s: %w", configFile, err))
continue
}
// Try to read and parse the file
pgCfg, err := parseConfigFile(configFile)
if err != nil {
if firstErr == nil {
firstErr = err
}
errs = append(errs, fmt.Errorf("%s: %w", configFile, err))
continue
}
return pgCfg, nil
return pgCfg, configFile, nil
}
if firstErr != nil {
return nil, fmt.Errorf("no config file found: %w", firstErr)
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")
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
@@ -135,7 +200,7 @@ func parseConfigFile(configFile string) (*database.PostgresConfig, error) {
if err != nil {
return nil, fmt.Errorf("failed to open config file: %w", err)
}
defer file.Close()
defer func() { _ = file.Close() }()
dec := yaml.NewDecoder(file)
cfg := database.Config{}
@@ -164,8 +229,9 @@ func parseConfigFile(configFile string) (*database.PostgresConfig, error) {
return nil, fmt.Errorf("no PostgreSQL configuration found in %s", configFile)
}
// getConfigFiles returns the list of config files to search
func getConfigFiles() []string {
// 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}
}

View File

@@ -1,9 +1,13 @@
package pgdb
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"go.ntppool.org/common/database"
)
@@ -112,8 +116,8 @@ func TestDefaultPoolOptions(t *testing.T) {
if opts.MinConns != 0 {
t.Errorf("Expected MinConns=0, got %d", opts.MinConns)
}
if opts.MaxConns != 25 {
t.Errorf("Expected MaxConns=25, got %d", opts.MaxConns)
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)
@@ -149,3 +153,143 @@ func TestCreatePoolConfigDefaults(t *testing.T) {
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)
}
}

View File

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

View File

@@ -34,6 +34,8 @@ import (
"fmt"
"net"
"net/http"
"runtime/debug"
"strings"
"time"
"github.com/labstack/echo-contrib/echoprometheus"
@@ -70,6 +72,7 @@ import (
// - WithGzipConfig(): Custom gzip compression settings
func New(name string, options ...func(*Ekko)) (*Ekko, error) {
ek := &Ekko{
name: name,
writeTimeout: 60 * time.Second,
readHeaderTimeout: 30 * time.Second,
}
@@ -77,6 +80,20 @@ func New(name string, options ...func(*Ekko)) (*Ekko, error) {
for _, o := range options {
o(ek)
}
if ek.name == "" {
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Path != "" {
if idx := strings.LastIndex(bi.Main.Path, "/"); idx >= 0 {
ek.name = bi.Main.Path[idx+1:]
} else {
ek.name = bi.Main.Path
}
}
if ek.name == "" {
ek.name = "ekko-app"
}
}
return ek, nil
}
@@ -146,6 +163,7 @@ func (ek *Ekko) setup(ctx context.Context) (*echo.Echo, error) {
echo.TrustLinkLocal(false),
echo.TrustPrivateNet(true),
}
trustOptions = append(trustOptions, ek.extraTrustOptions...)
e.IPExtractor = echo.ExtractIPFromXFFHeader(trustOptions...)
if ek.otelmiddleware == nil {
@@ -162,12 +180,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{

View File

@@ -13,13 +13,14 @@ import (
// It encapsulates server configuration, middleware options, and lifecycle management
// for NTP Pool web services. Use New() with functional options to configure.
type Ekko struct {
name string
prom prometheus.Registerer
port int
routeFn func(e *echo.Echo) error
logFilters []slogecho.Filter
otelmiddleware echo.MiddlewareFunc
gzipConfig *middleware.GzipConfig
name string
prom prometheus.Registerer
port int
routeFn func(e *echo.Echo) error
logFilters []slogecho.Filter
otelmiddleware echo.MiddlewareFunc
gzipConfig *middleware.GzipConfig
extraTrustOptions []echo.TrustOption
writeTimeout time.Duration
readHeaderTimeout time.Duration
@@ -92,6 +93,16 @@ func WithReadHeaderTimeout(t time.Duration) func(*Ekko) {
}
}
// WithTrustOptions appends additional trust options to the default IP extraction
// configuration. These options are applied after the built-in trust settings
// (loopback trusted, link-local untrusted, private networks trusted) when
// extracting client IPs from the X-Forwarded-For header.
func WithTrustOptions(opts ...echo.TrustOption) func(*Ekko) {
return func(ek *Ekko) {
ek.extraTrustOptions = append(ek.extraTrustOptions, opts...)
}
}
// WithGzipConfig provides custom gzip compression configuration.
// By default, gzip compression is enabled with standard settings.
// Use this option to customize compression level, skip patterns, or disable compression.

123
go.mod
View File

@@ -1,91 +1,94 @@
module go.ntppool.org/common
go 1.24.0
go 1.25.0
require (
github.com/abh/certman v0.4.0
github.com/go-sql-driver/mysql v1.9.3
github.com/jackc/pgx/v5 v5.7.6
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.28.0
golang.org/x/net v0.44.0
golang.org/x/sync v0.17.0
google.golang.org/grpc v1.69.2
github.com/jackc/pgx/v5 v5.9.0
github.com/labstack/echo-contrib v0.50.1
github.com/labstack/echo/v4 v4.15.1
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.5
github.com/samber/slog-echo v1.21.0
github.com/samber/slog-multi v1.7.1
github.com/segmentio/kafka-go v0.4.50
github.com/spf13/cobra v1.10.2
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0
go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.67.0
go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0
go.opentelemetry.io/otel/log v0.18.0
go.opentelemetry.io/otel/metric v1.42.0
go.opentelemetry.io/otel/sdk v1.42.0
go.opentelemetry.io/otel/sdk/log v0.18.0
go.opentelemetry.io/otel/sdk/metric v1.42.0
go.opentelemetry.io/otel/trace v1.42.0
golang.org/x/mod v0.34.0
golang.org/x/net v0.52.0
golang.org/x/sync v0.20.0
google.golang.org/grpc v1.79.3
gopkg.in/yaml.v3 v3.0.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
filippo.io/edwards25519 v1.2.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/dmarkham/enumer v1.6.1 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-logr/logr v1.4.2 // 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.28.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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.17.11 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/masaushi/accessory v0.6.0 // indirect
github.com/mattn/go-colorable v0.1.13 // 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/pascaldekloe/name v1.0.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // 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/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/samber/slog-common v0.20.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/pflag v1.0.5 // 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.42.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.8.0 // indirect
golang.org/x/tools v0.37.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.67.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
tool (

313
go.sum
View File

@@ -1,119 +1,118 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
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.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
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.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE=
github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
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.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
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=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/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/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-contrib v0.50.1 h1:W9cZZ9viA4TDdFtm8cuA+XGFwOcnfbjJpl7VgfsRLHE=
github.com/labstack/echo-contrib v0.50.1/go.mod h1:8r/++U/Fw/QniApFnzunLanKaviPfBX7fX7/2QX0qOk=
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
github.com/labstack/echo/v4 v4.15.1/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-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/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.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/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_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
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/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
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/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.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
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.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/remychantenay/slog-otel v1.3.5 h1:VBxvLh6wJ+ioY9Lup66Bin8UWzRYdVYCDhr8cpBneM4=
github.com/remychantenay/slog-otel v1.3.5/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/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-multi v1.2.4 h1:k9x3JAWKJFPKffx+oXZ8TasaNuorIW4tG+TXxkt6Ry4=
github.com/samber/slog-multi v1.2.4/go.mod h1:ACuZ5B6heK57TfMVkVknN2UZHoFfjCwRxR0Q2OXKHlo=
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/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/samber/slog-common v0.20.0 h1:WaLnm/aCvBJSk5nR5aXZTFBaV0B47A+AEaEOiZDeUnc=
github.com/samber/slog-common v0.20.0/go.mod h1:+Ozat1jgnnE59UAlmNX1IF3IByHsODnnwf9jUcBZ+m8=
github.com/samber/slog-echo v1.21.0 h1:7qyzNeYTpbCbBrlF7C3XY1gsG5LHdrDVd0Ci9mSWRl0=
github.com/samber/slog-echo v1.21.0/go.mod h1:caG3zeXgrPRlGKaPVqyWG1MEc6nwrmtDjoLN/mc0PrM=
github.com/samber/slog-multi v1.7.1 h1:aCLXHRxgU+2v0PVlEOh7phynzM7CRo89ZgFtOwaqVEE=
github.com/samber/slog-multi v1.7.1/go.mod h1:A4KQC99deqfkCDJcL/cO3kX6McX7FffQAx/8QHink+c=
github.com/segmentio/kafka-go v0.4.50 h1:mcyC3tT5WeyWzrFbd6O374t+hmcu1NKt2Pu1L3QaXmc=
github.com/segmentio/kafka-go v0.4.50/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/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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.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=
@@ -124,124 +123,90 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
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/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/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/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/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/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/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/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/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/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/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/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/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.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0/go.mod h1:39SaByOyDMRMe872AE7uelMuQZidIw7LLFAnQi0FWTE=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4=
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8=
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ=
go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.67.0 h1:0FKdyaoWXDmSCpQuv3m2UiJIRNxb1CK1mILy5QyKxc4=
go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.67.0/go.mod h1:IXtTS6zjKfM2yNRD9rWOS7SfIYGtuLGhL9ent5WX3Uk=
go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU=
go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg=
go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
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.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
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.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
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.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.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=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.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=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
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/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.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
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=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
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-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=

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

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

View File

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

View File

@@ -23,6 +23,9 @@ type bufferingExporter struct {
// Real exporter (created when tracing is configured)
exporter otellog.Exporter
// Track whether buffer has been flushed (separate from exporter creation)
bufferFlushed bool
// Thread-safe initialization state (managed only by checkReadiness)
initErr error
@@ -71,22 +74,63 @@ func (e *bufferingExporter) initialize() error {
initCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
exporter, err := factory(initCtx, cfg)
if err != nil {
return fmt.Errorf("failed to create OTLP exporter: %w", err)
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
} else {
// Another goroutine beat us, close the one we created
_ = exporter.Shutdown(context.Background())
}
e.mu.Unlock()
}
// 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()
e.exporter = exporter
flushErr := e.flushBuffer(initCtx)
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()
if flushErr != nil {
// Log but don't fail initialization
Setup().Warn("buffer flush failed during initialization", "error", flushErr)
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
}
return nil
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
@@ -119,16 +163,16 @@ func (e *bufferingExporter) checkReadiness() {
for {
select {
case <-ticker.C:
// Check if we already have a working exporter
// Check if we're fully ready (exporter created AND buffer flushed)
e.mu.RLock()
hasExporter := e.exporter != nil
fullyReady := e.exporter != nil && e.bufferFlushed
e.mu.RUnlock()
if hasExporter {
return // Exporter ready, checker no longer needed
if fullyReady {
return // Fully initialized, checker no longer needed
}
// Try to initialize
// Try to initialize (creates exporter and flushes if token ready)
err := e.initialize()
e.mu.Lock()
e.initErr = err
@@ -182,18 +226,42 @@ func (e *bufferingExporter) Shutdown(ctx context.Context) error {
// 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()

View File

@@ -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 (
@@ -21,15 +39,32 @@ import (
// Metrics provides a custom Prometheus registry and HTTP handlers for metrics exposure.
// It isolates application metrics from the default global registry.
type Metrics struct {
r *prometheus.Registry
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,
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)

View File

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

View File

@@ -2,7 +2,7 @@
set -euo pipefail
go install github.com/goreleaser/goreleaser/v2@v2.12.3
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

View File

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

View File

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

View File

@@ -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
@@ -398,10 +402,10 @@ func TestParseBuildTimeConsistency(t *testing.T) {
func BenchmarkParseBuildTime(b *testing.B) {
inputs := []string{
"1672531200", // Unix epoch
"2023-01-01T00:00:00Z", // RFC3339
"invalid-timestamp", // Invalid
"", // Empty
"1672531200", // Unix epoch
"2023-01-01T00:00:00Z", // RFC3339
"invalid-timestamp", // Invalid
"", // Empty
}
for _, input := range inputs {

48
xff/echo/echo.go Normal file
View File

@@ -0,0 +1,48 @@
// Package xffecho adapts [xff.TrustedProxies] for use with the Echo web
// framework's X-Forwarded-For IP extraction.
//
// # Usage
//
// tp, err := fastlyxff.New("fastly.json")
// if err != nil {
// return err
// }
// trustOpts := xffecho.TrustOptions(tp)
// e.IPExtractor = echo.ExtractIPFromXFFHeader(trustOpts...)
package xffecho
import (
"net"
"net/netip"
"github.com/labstack/echo/v4"
"go.ntppool.org/common/xff"
)
// TrustOptions converts a [xff.TrustedProxies] into Echo trust options
// for use with [echo.ExtractIPFromXFFHeader].
func TrustOptions(tp *xff.TrustedProxies) []echo.TrustOption {
prefixes := tp.Prefixes()
opts := make([]echo.TrustOption, 0, len(prefixes))
for _, p := range prefixes {
opts = append(opts, echo.TrustIPRange(prefixToIPNet(p)))
}
return opts
}
// prefixToIPNet bridges netip.Prefix (used by xff) to net.IPNet (used by Echo).
func prefixToIPNet(p netip.Prefix) *net.IPNet {
addr := p.Masked().Addr()
bits := p.Bits()
ipLen := 128
if addr.Is4() {
ipLen = 32
}
return &net.IPNet{
IP: net.IP(addr.AsSlice()),
Mask: net.CIDRMask(bits, ipLen),
}
}

31
xff/echo/echo_test.go Normal file
View File

@@ -0,0 +1,31 @@
package xffecho
import (
"testing"
"go.ntppool.org/common/xff"
)
func TestTrustOptions(t *testing.T) {
tp, err := xff.NewFromCIDRs([]string{
"192.0.2.0/24",
"203.0.113.0/24",
"2001:db8::/32",
})
if err != nil {
t.Fatal(err)
}
opts := TrustOptions(tp)
if len(opts) != 3 {
t.Errorf("expected 3 trust options, got %d", len(opts))
}
}
func TestTrustOptionsEmpty(t *testing.T) {
tp := xff.New()
opts := TrustOptions(tp)
if len(opts) != 0 {
t.Errorf("expected 0 trust options, got %d", len(opts))
}
}

View File

@@ -1,270 +1,53 @@
// Package fastlyxff provides Fastly CDN IP range management for trusted proxy handling.
// Package fastlyxff loads Fastly CDN IP ranges and returns a generic
// [xff.TrustedProxies] for trusted proxy handling.
//
// 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
// X-Forwarded-For headers are only trusted when they originate from legitimate
// Fastly edge servers.
//
// Key features:
// - 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:
// Fastly publishes their edge server IP ranges in a JSON format:
//
// {
// "addresses": ["23.235.32.0/20", "43.249.72.0/22", ...],
// "ipv6_addresses": ["2a04:4e40::/32", "2a04:4e42::/32", ...]
// }
//
// # Usage
//
// tp, err := fastlyxff.New("fastly.json")
// if err != nil {
// return err
// }
// // Use tp.HTTPMiddleware(), tp.ExtractRealIP(r), etc.
// // For Echo framework, use the xff/echo package:
// // opts, err := xffecho.TrustOptions(tp)
package fastlyxff
import (
"context"
"encoding/json"
"net"
"net/http"
"net/netip"
"os"
"strings"
"github.com/labstack/echo/v4"
"go.ntppool.org/common/xff"
)
// 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, 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
// fastlyIPRanges matches the JSON format published by Fastly for their
// edge server IP ranges.
type fastlyIPRanges struct {
IPv4 []string `json:"addresses"`
IPv6 []string `json:"ipv6_addresses"`
}
// TrustedNets holds parsed network prefixes for efficient IP range checking.
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.
//
// Parameters:
// - fileName: Path to the Fastly IP ranges JSON file
//
// Returns the parsed FastlyXFF structure or an error if the file cannot be
// read or the JSON format is invalid.
func New(fileName string) (*FastlyXFF, error) {
// New loads Fastly IP ranges from a JSON file and returns a [xff.TrustedProxies].
func New(fileName string) (*xff.TrustedProxies, error) {
b, err := os.ReadFile(fileName)
if err != nil {
return nil, err
}
d := FastlyXFF{}
err = json.Unmarshal(b, &d)
if err != nil {
var ranges fastlyIPRanges
if err := json.Unmarshal(b, &ranges); err != nil {
return nil, err
}
return &d, nil
}
// EchoTrustOption converts Fastly IP ranges into Echo framework trust options.
// This method generates trust configurations that tell Echo to accept X-Forwarded-For
// headers only from Fastly's edge servers, ensuring accurate client IP extraction.
//
// The generated trust options should be used with Echo's IP extractor:
//
// options, err := fastlyRanges.EchoTrustOption()
// if err != nil {
// return err
// }
// e.IPExtractor = echo.ExtractIPFromXFFHeader(options...)
//
// Returns a slice of Echo trust options or an error if any CIDR range cannot be parsed.
func (xff *FastlyXFF) EchoTrustOption() ([]echo.TrustOption, error) {
ranges := []echo.TrustOption{}
for _, s := range append(xff.IPv4, xff.IPv6...) {
_, cidr, err := net.ParseCIDR(s)
if err != nil {
return nil, err
}
trust := echo.TrustIPRange(cidr)
ranges = append(ranges, trust)
}
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
cidrs := make([]string, 0, len(ranges.IPv4)+len(ranges.IPv6))
cidrs = append(cidrs, ranges.IPv4...)
cidrs = append(cidrs, ranges.IPv6...)
return xff.NewFromCIDRs(cidrs)
}

View File

@@ -1,356 +1,44 @@
package fastlyxff
import (
"net"
"net/http"
"net/http/httptest"
"os"
"testing"
)
func TestFastlyIPRanges(t *testing.T) {
fastlyxff, err := New("fastly.json")
func TestNew(t *testing.T) {
tp, err := New("fastly.json")
if err != nil {
t.Fatalf("could not load test data: %s", err)
}
data, err := fastlyxff.EchoTrustOption()
prefixes := tp.Prefixes()
if len(prefixes) < 10 {
t.Errorf("only got %d prefixes, expected more", len(prefixes))
}
}
func TestNewFileNotFound(t *testing.T) {
_, err := New("nonexistent.json")
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestNewInvalidJSON(t *testing.T) {
// Create a temp file with invalid JSON
f, err := os.CreateTemp("", "fastlyxff-test-*.json")
if err != nil {
t.Fatalf("could not parse test data: %s", err)
t.Fatal(err)
}
defer os.Remove(f.Name())
if len(data) < 10 {
t.Logf("only got %d prefixes, expected more", len(data))
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)
}
})
if _, err := f.WriteString("{invalid"); err != nil {
t.Fatal(err)
}
f.Close()
_, err = New(f.Name())
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}

163
xff/xff.go Normal file
View File

@@ -0,0 +1,163 @@
// Package xff provides trusted proxy handling and real client IP extraction
// from X-Forwarded-For headers.
//
// This package has no external dependencies — it uses only the Go standard library.
//
// The XFF extraction algorithm walks right-to-left through the X-Forwarded-For
// header, skipping trusted proxy IPs, and returns the first untrusted IP as the
// real client address. This follows the MDN-recommended approach for secure
// client IP extraction.
//
// # Usage with net/http middleware
//
// tp, err := xff.NewFromCIDRs([]string{"10.0.0.0/8", "192.168.0.0/16"})
// if err != nil {
// return err
// }
// handler := tp.HTTPMiddleware()(yourHandler)
//
// # Direct extraction
//
// realIP := tp.ExtractRealIP(r)
package xff
import (
"context"
"net"
"net/http"
"net/netip"
"strings"
)
// TrustedProxies holds a set of trusted proxy network prefixes and provides
// methods for extracting the real client IP from X-Forwarded-For headers.
type TrustedProxies struct {
prefixes []netip.Prefix
}
type contextKey string
const realIPKey contextKey = "xff-real-ip"
// New creates a TrustedProxies from already-parsed prefixes.
func New(prefixes ...netip.Prefix) *TrustedProxies {
return &TrustedProxies{prefixes: prefixes}
}
// NewFromCIDRs creates a TrustedProxies from CIDR strings (e.g., "10.0.0.0/8").
func NewFromCIDRs(cidrs []string) (*TrustedProxies, error) {
prefixes := make([]netip.Prefix, 0, len(cidrs))
for _, s := range cidrs {
p, err := netip.ParsePrefix(s)
if err != nil {
return nil, err
}
prefixes = append(prefixes, p)
}
return &TrustedProxies{prefixes: prefixes}, nil
}
// AddCIDR adds a CIDR string to the trusted proxy list.
func (tp *TrustedProxies) AddCIDR(cidr string) error {
p, err := netip.ParsePrefix(cidr)
if err != nil {
return err
}
tp.prefixes = append(tp.prefixes, p)
return nil
}
// AddPrefix adds a parsed prefix to the trusted proxy list.
func (tp *TrustedProxies) AddPrefix(prefix netip.Prefix) {
tp.prefixes = append(tp.prefixes, prefix)
}
// Prefixes returns a copy of the trusted proxy prefixes.
func (tp *TrustedProxies) Prefixes() []netip.Prefix {
out := make([]netip.Prefix, len(tp.prefixes))
copy(out, tp.prefixes)
return out
}
// IsTrusted reports whether ip belongs to any of the trusted proxy ranges.
func (tp *TrustedProxies) IsTrusted(ip string) bool {
addr, err := netip.ParseAddr(ip)
if err != nil {
return false
}
return tp.isTrustedAddr(addr)
}
func (tp *TrustedProxies) isTrustedAddr(addr netip.Addr) bool {
for _, p := range tp.prefixes {
if p.Contains(addr) {
return true
}
}
return false
}
// ExtractRealIP extracts the real client IP from a request by walking the
// X-Forwarded-For header right-to-left, skipping trusted proxy IPs.
// If the immediate peer is not a trusted proxy, its IP is returned.
func (tp *TrustedProxies) ExtractRealIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
hostAddr, err := netip.ParseAddr(host)
if err != nil || !tp.isTrustedAddr(hostAddr) {
return host
}
xffHeader := r.Header.Get("X-Forwarded-For")
if xffHeader == "" {
return host
}
ips := strings.Split(xffHeader, ",")
// Walk right-to-left: skip trusted proxies, return first untrusted IP.
for i := len(ips) - 1; i >= 0; i-- {
ip := strings.TrimSpace(ips[i])
if ip == "" {
continue
}
addr, err := netip.ParseAddr(ip)
if err != nil || !tp.isTrustedAddr(addr) {
return ip
}
}
return host
}
// HTTPMiddleware returns a net/http middleware that extracts the real client IP
// from X-Forwarded-For headers and stores it in the request context and
// RemoteAddr. The port in RemoteAddr is set to 0 because the original port
// belongs to the proxy connection, not the real client.
func (tp *TrustedProxies) HTTPMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
realIP := tp.ExtractRealIP(r)
ctx := context.WithValue(r.Context(), realIPKey, realIP)
r = r.WithContext(ctx)
r.RemoteAddr = net.JoinHostPort(realIP, "0")
next.ServeHTTP(w, r)
})
}
}
// GetRealIP retrieves the real client IP from the request context.
// Returns the remote address host if no real IP was extracted by middleware.
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
}

225
xff/xff_test.go Normal file
View File

@@ -0,0 +1,225 @@
package xff
import (
"net"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
)
func testProxies(t *testing.T) *TrustedProxies {
t.Helper()
tp, err := NewFromCIDRs([]string{"192.0.2.0/24", "203.0.113.0/24", "2001:db8::/32"})
if err != nil {
t.Fatal(err)
}
return tp
}
func TestNew(t *testing.T) {
p := netip.MustParsePrefix("10.0.0.0/8")
tp := New(p)
if len(tp.Prefixes()) != 1 {
t.Fatalf("expected 1 prefix, got %d", len(tp.Prefixes()))
}
}
func TestNewFromCIDRs(t *testing.T) {
_, err := NewFromCIDRs([]string{"not-a-cidr"})
if err == nil {
t.Fatal("expected error for invalid CIDR")
}
}
func TestIsTrusted(t *testing.T) {
tp := testProxies(t)
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) {
if got := tp.IsTrusted(tt.ip); got != tt.expected {
t.Errorf("IsTrusted(%s) = %v, want %v", tt.ip, got, tt.expected)
}
})
}
}
func TestAddCIDR(t *testing.T) {
tp := testProxies(t)
if err := tp.AddCIDR("10.0.0.0/8"); err != nil {
t.Fatal(err)
}
if !tp.IsTrusted("10.1.2.3") {
t.Error("expected 10.1.2.3 to be trusted after AddCIDR")
}
if err := tp.AddCIDR("bad"); err == nil {
t.Error("expected error for invalid CIDR")
}
}
func TestAddPrefix(t *testing.T) {
tp := testProxies(t)
tp.AddPrefix(netip.MustParsePrefix("172.16.0.0/12"))
if !tp.IsTrusted("172.16.1.1") {
t.Error("expected 172.16.1.1 to be trusted after AddPrefix")
}
}
func TestExtractRealIP(t *testing.T) {
tp := testProxies(t)
tests := []struct {
name string
remoteAddr string
xForwardedFor string
expected string
}{
{
name: "no XFF, untrusted peer",
remoteAddr: "198.51.100.1:12345",
expected: "198.51.100.1",
},
{
name: "trusted proxy, single XFF",
remoteAddr: "192.0.2.1:80",
xForwardedFor: "198.51.100.1",
expected: "198.51.100.1",
},
{
name: "trusted proxy, empty XFF",
remoteAddr: "192.0.2.1:80",
xForwardedFor: "",
expected: "192.0.2.1",
},
{
name: "untrusted peer ignores XFF",
remoteAddr: "198.51.100.1:80",
xForwardedFor: "10.0.0.1",
expected: "198.51.100.1",
},
{
name: "malformed remote addr",
remoteAddr: "192.0.2.1",
xForwardedFor: "198.51.100.1",
expected: "198.51.100.1",
},
// Right-to-left: "client, proxy1(trusted)" -> skip proxy1, return client
{
name: "right-to-left skips trusted proxies in XFF",
remoteAddr: "192.0.2.1:80",
xForwardedFor: "198.51.100.1, 203.0.113.1",
expected: "198.51.100.1",
},
// Right-to-left: "spoofed, real-client, trusted-proxy"
// should return real-client (first untrusted from right)
{
name: "right-to-left stops at first untrusted from right",
remoteAddr: "192.0.2.1:80",
xForwardedFor: "198.51.100.50, 198.51.100.99, 203.0.113.1",
expected: "198.51.100.99",
},
{
name: "IPv6 trusted proxy",
remoteAddr: "[2001:db8::1]:80",
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)
}
if got := tp.ExtractRealIP(req); got != tt.expected {
t.Errorf("ExtractRealIP() = %s, want %s", got, tt.expected)
}
})
}
}
func TestHTTPMiddleware(t *testing.T) {
tp := testProxies(t)
mw := tp.HTTPMiddleware()
tests := []struct {
name string
remoteAddr string
xForwardedFor string
expectedRealIP string
}{
{
name: "direct connection",
remoteAddr: "198.51.100.1:12345",
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: "untrusted proxy ignored",
remoteAddr: "198.51.100.2:80",
xForwardedFor: "10.0.0.1",
expectedRealIP: "198.51.100.2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedRealIP, capturedRemoteAddr string
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedRealIP = GetRealIP(r)
capturedRemoteAddr = r.RemoteAddr
})
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = tt.remoteAddr
if tt.xForwardedFor != "" {
req.Header.Set("X-Forwarded-For", tt.xForwardedFor)
}
rr := httptest.NewRecorder()
mw(handler).ServeHTTP(rr, req)
if capturedRealIP != tt.expectedRealIP {
t.Errorf("GetRealIP: got %s, want %s", capturedRealIP, tt.expectedRealIP)
}
expectedAddr := net.JoinHostPort(tt.expectedRealIP, "0")
if capturedRemoteAddr != expectedAddr {
t.Errorf("RemoteAddr: got %s, want %s", capturedRemoteAddr, expectedAddr)
}
})
}
}
func TestGetRealIPWithoutMiddleware(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "198.51.100.1:12345"
if got := GetRealIP(req); got != "198.51.100.1" {
t.Errorf("GetRealIP() = %s, want 198.51.100.1", got)
}
}