feat(metrics): add OTLP metrics support with centralized config
- Create new metrics/ package for OpenTelemetry-native metrics with OTLP export - Refactor OTLP configuration to internal/tracerconfig/ to eliminate code duplication - Add consistent retry configuration across all HTTP OTLP exporters - Add configuration validation and improved error messages - Include test coverage for all new functionality - Make OpenTelemetry metrics dependencies explicit in go.mod Designed for new applications requiring structured metrics export to observability backends via OTLP protocol.
This commit is contained in:
474
internal/tracerconfig/config_test.go
Normal file
474
internal/tracerconfig/config_test.go
Normal file
@@ -0,0 +1,474 @@
|
||||
package tracerconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdklog "go.opentelemetry.io/otel/sdk/log"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
func TestStore_And_Retrieve(t *testing.T) {
|
||||
// Clear any existing configuration
|
||||
Clear()
|
||||
|
||||
ctx := context.Background()
|
||||
config := &Config{
|
||||
ServiceName: "test-service",
|
||||
Environment: "test",
|
||||
Endpoint: "localhost:4317",
|
||||
}
|
||||
|
||||
// Create mock factories
|
||||
logFactory := func(context.Context, *Config) (sdklog.Exporter, error) { return nil, nil }
|
||||
metricFactory := func(context.Context, *Config) (sdkmetric.Exporter, error) { return nil, nil }
|
||||
traceFactory := func(context.Context, *Config) (sdktrace.SpanExporter, error) { return nil, nil }
|
||||
|
||||
// Store configuration
|
||||
Store(ctx, config, logFactory, metricFactory, traceFactory)
|
||||
|
||||
// Test IsConfigured
|
||||
if !IsConfigured() {
|
||||
t.Error("IsConfigured() should return true after Store()")
|
||||
}
|
||||
|
||||
// Test GetLogExporter
|
||||
cfg, ctx2, factory := GetLogExporter()
|
||||
if cfg == nil || ctx2 == nil || factory == nil {
|
||||
t.Error("GetLogExporter() should return non-nil values")
|
||||
}
|
||||
if cfg.ServiceName != "test-service" {
|
||||
t.Errorf("Expected ServiceName 'test-service', got '%s'", cfg.ServiceName)
|
||||
}
|
||||
|
||||
// Test GetMetricExporter
|
||||
cfg, ctx3, metricFact := GetMetricExporter()
|
||||
if cfg == nil || ctx3 == nil || metricFact == nil {
|
||||
t.Error("GetMetricExporter() should return non-nil values")
|
||||
}
|
||||
|
||||
// Test GetTraceExporter
|
||||
cfg, ctx4, traceFact := GetTraceExporter()
|
||||
if cfg == nil || ctx4 == nil || traceFact == nil {
|
||||
t.Error("GetTraceExporter() should return non-nil values")
|
||||
}
|
||||
|
||||
// Test backward compatibility Get()
|
||||
cfg, ctx5, logFact := Get()
|
||||
if cfg == nil || ctx5 == nil || logFact == nil {
|
||||
t.Error("Get() should return non-nil values for backward compatibility")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClear(t *testing.T) {
|
||||
// Store some configuration first
|
||||
ctx := context.Background()
|
||||
config := &Config{ServiceName: "test"}
|
||||
Store(ctx, config, nil, nil, nil)
|
||||
|
||||
if !IsConfigured() {
|
||||
t.Error("Should be configured before Clear()")
|
||||
}
|
||||
|
||||
// Clear configuration
|
||||
Clear()
|
||||
|
||||
if IsConfigured() {
|
||||
t.Error("Should not be configured after Clear()")
|
||||
}
|
||||
|
||||
// All getters should return nil
|
||||
cfg, ctx2, factory := GetLogExporter()
|
||||
if cfg != nil || ctx2 != nil || factory != nil {
|
||||
t.Error("GetLogExporter() should return nil values after Clear()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
Clear()
|
||||
|
||||
ctx := context.Background()
|
||||
config := &Config{ServiceName: "concurrent-test"}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const numGoroutines = 10
|
||||
|
||||
// Test concurrent Store and Get operations
|
||||
wg.Add(numGoroutines * 2)
|
||||
|
||||
// Concurrent Store operations
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
Store(ctx, config, nil, nil, nil)
|
||||
}()
|
||||
}
|
||||
|
||||
// Concurrent Get operations
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
IsConfigured()
|
||||
GetLogExporter()
|
||||
GetMetricExporter()
|
||||
GetTraceExporter()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Should be configured after all operations
|
||||
if !IsConfigured() {
|
||||
t.Error("Should be configured after concurrent operations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTLSConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
expected bool // whether TLS config should be nil
|
||||
}{
|
||||
{
|
||||
name: "nil certificate provider",
|
||||
config: &Config{},
|
||||
expected: true, // should be nil
|
||||
},
|
||||
{
|
||||
name: "with certificate provider",
|
||||
config: &Config{
|
||||
CertificateProvider: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
|
||||
return &tls.Certificate{}, nil
|
||||
},
|
||||
},
|
||||
expected: false, // should not be nil
|
||||
},
|
||||
{
|
||||
name: "with certificate provider and RootCAs",
|
||||
config: &Config{
|
||||
CertificateProvider: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
|
||||
return &tls.Certificate{}, nil
|
||||
},
|
||||
RootCAs: x509.NewCertPool(),
|
||||
},
|
||||
expected: false, // should not be nil
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tlsConfig := getTLSConfig(tt.config)
|
||||
if tt.expected && tlsConfig != nil {
|
||||
t.Errorf("Expected nil TLS config, got %v", tlsConfig)
|
||||
}
|
||||
if !tt.expected && tlsConfig == nil {
|
||||
t.Error("Expected non-nil TLS config, got nil")
|
||||
}
|
||||
if !tt.expected && tlsConfig != nil {
|
||||
if tlsConfig.GetClientCertificate == nil {
|
||||
t.Error("Expected GetClientCertificate to be set")
|
||||
}
|
||||
if tt.config.RootCAs != nil && tlsConfig.RootCAs != tt.config.RootCAs {
|
||||
t.Error("Expected RootCAs to be set correctly")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProtocol(t *testing.T) {
|
||||
// Save original env vars
|
||||
originalGeneral := os.Getenv(otelExporterOTLPProtoEnvKey)
|
||||
originalLogs := os.Getenv(otelExporterOTLPLogsProtoEnvKey)
|
||||
|
||||
defer func() {
|
||||
// Restore original env vars
|
||||
if originalGeneral != "" {
|
||||
os.Setenv(otelExporterOTLPProtoEnvKey, originalGeneral)
|
||||
} else {
|
||||
os.Unsetenv(otelExporterOTLPProtoEnvKey)
|
||||
}
|
||||
if originalLogs != "" {
|
||||
os.Setenv(otelExporterOTLPLogsProtoEnvKey, originalLogs)
|
||||
} else {
|
||||
os.Unsetenv(otelExporterOTLPLogsProtoEnvKey)
|
||||
}
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
signalSpecific string
|
||||
generalProto string
|
||||
specificProto string
|
||||
expectedResult string
|
||||
}{
|
||||
{
|
||||
name: "no env vars set - default",
|
||||
signalSpecific: otelExporterOTLPLogsProtoEnvKey,
|
||||
expectedResult: "http/protobuf",
|
||||
},
|
||||
{
|
||||
name: "general env var set",
|
||||
signalSpecific: otelExporterOTLPLogsProtoEnvKey,
|
||||
generalProto: "grpc",
|
||||
expectedResult: "grpc",
|
||||
},
|
||||
{
|
||||
name: "specific env var overrides general",
|
||||
signalSpecific: otelExporterOTLPLogsProtoEnvKey,
|
||||
generalProto: "grpc",
|
||||
specificProto: "http/protobuf",
|
||||
expectedResult: "http/protobuf",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Clear env vars
|
||||
os.Unsetenv(otelExporterOTLPProtoEnvKey)
|
||||
os.Unsetenv(otelExporterOTLPLogsProtoEnvKey)
|
||||
|
||||
// Set test env vars
|
||||
if tt.generalProto != "" {
|
||||
os.Setenv(otelExporterOTLPProtoEnvKey, tt.generalProto)
|
||||
}
|
||||
if tt.specificProto != "" {
|
||||
os.Setenv(tt.signalSpecific, tt.specificProto)
|
||||
}
|
||||
|
||||
result := getProtocol(tt.signalSpecific)
|
||||
if result != tt.expectedResult {
|
||||
t.Errorf("Expected protocol '%s', got '%s'", tt.expectedResult, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExporterErrors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
config := &Config{
|
||||
ServiceName: "test-service",
|
||||
Endpoint: "invalid-endpoint",
|
||||
}
|
||||
|
||||
// Test with invalid protocol for logs
|
||||
os.Setenv(otelExporterOTLPLogsProtoEnvKey, "invalid-protocol")
|
||||
defer os.Unsetenv(otelExporterOTLPLogsProtoEnvKey)
|
||||
|
||||
_, err := CreateOTLPLogExporter(ctx, config)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid protocol")
|
||||
}
|
||||
// Check that it's a protocol error (the specific message will be different now)
|
||||
if !strings.Contains(err.Error(), "invalid OTLP protocol") {
|
||||
t.Errorf("Expected protocol error, got %v", err)
|
||||
}
|
||||
|
||||
// Test with invalid protocol for metrics
|
||||
os.Setenv(otelExporterOTLPMetricsProtoEnvKey, "invalid-protocol")
|
||||
defer os.Unsetenv(otelExporterOTLPMetricsProtoEnvKey)
|
||||
|
||||
_, err = CreateOTLPMetricExporter(ctx, config)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid protocol")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid OTLP protocol") {
|
||||
t.Errorf("Expected protocol error, got %v", err)
|
||||
}
|
||||
|
||||
// Test with invalid protocol for traces
|
||||
os.Setenv(otelExporterOTLPTracesProtoEnvKey, "invalid-protocol")
|
||||
defer os.Unsetenv(otelExporterOTLPTracesProtoEnvKey)
|
||||
|
||||
_, err = CreateOTLPTraceExporter(ctx, config)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid protocol")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid OTLP protocol") {
|
||||
t.Errorf("Expected protocol error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExporterValidProtocols(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
config := &Config{
|
||||
ServiceName: "test-service",
|
||||
Endpoint: "localhost:4317", // This will likely fail to connect, but should create exporter
|
||||
}
|
||||
|
||||
protocols := []string{"grpc", "http/protobuf", "http/json"}
|
||||
|
||||
for _, proto := range protocols {
|
||||
t.Run("logs_"+proto, func(t *testing.T) {
|
||||
os.Setenv(otelExporterOTLPLogsProtoEnvKey, proto)
|
||||
defer os.Unsetenv(otelExporterOTLPLogsProtoEnvKey)
|
||||
|
||||
exporter, err := CreateOTLPLogExporter(ctx, config)
|
||||
if err != nil {
|
||||
// Connection errors are expected since we're not running a real OTLP server
|
||||
// but the exporter should be created successfully
|
||||
t.Logf("Connection error expected: %v", err)
|
||||
}
|
||||
if exporter != nil {
|
||||
exporter.Shutdown(ctx)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("metrics_"+proto, func(t *testing.T) {
|
||||
os.Setenv(otelExporterOTLPMetricsProtoEnvKey, proto)
|
||||
defer os.Unsetenv(otelExporterOTLPMetricsProtoEnvKey)
|
||||
|
||||
exporter, err := CreateOTLPMetricExporter(ctx, config)
|
||||
if err != nil {
|
||||
t.Logf("Connection error expected: %v", err)
|
||||
}
|
||||
if exporter != nil {
|
||||
exporter.Shutdown(ctx)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("traces_"+proto, func(t *testing.T) {
|
||||
os.Setenv(otelExporterOTLPTracesProtoEnvKey, proto)
|
||||
defer os.Unsetenv(otelExporterOTLPTracesProtoEnvKey)
|
||||
|
||||
exporter, err := CreateOTLPTraceExporter(ctx, config)
|
||||
if err != nil {
|
||||
t.Logf("Connection error expected: %v", err)
|
||||
}
|
||||
if exporter != nil {
|
||||
exporter.Shutdown(ctx)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
shouldErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid empty config",
|
||||
config: &Config{},
|
||||
shouldErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid config with endpoint",
|
||||
config: &Config{
|
||||
ServiceName: "test-service",
|
||||
Endpoint: "localhost:4317",
|
||||
},
|
||||
shouldErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid config with endpoint URL",
|
||||
config: &Config{
|
||||
ServiceName: "test-service",
|
||||
EndpointURL: "https://otlp.example.com:4317/v1/traces",
|
||||
},
|
||||
shouldErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid - both endpoint and endpoint URL",
|
||||
config: &Config{
|
||||
ServiceName: "test-service",
|
||||
Endpoint: "localhost:4317",
|
||||
EndpointURL: "https://otlp.example.com:4317/v1/traces",
|
||||
},
|
||||
shouldErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - endpoint with protocol",
|
||||
config: &Config{
|
||||
ServiceName: "test-service",
|
||||
Endpoint: "https://localhost:4317",
|
||||
},
|
||||
shouldErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - empty endpoint",
|
||||
config: &Config{
|
||||
ServiceName: "test-service",
|
||||
Endpoint: " ",
|
||||
},
|
||||
shouldErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - malformed endpoint URL",
|
||||
config: &Config{
|
||||
ServiceName: "test-service",
|
||||
EndpointURL: "://invalid-url-missing-scheme",
|
||||
},
|
||||
shouldErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - empty service name",
|
||||
config: &Config{
|
||||
ServiceName: " ",
|
||||
Endpoint: "localhost:4317",
|
||||
},
|
||||
shouldErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if tt.shouldErr && err == nil {
|
||||
t.Error("Expected validation error, got nil")
|
||||
}
|
||||
if !tt.shouldErr && err != nil {
|
||||
t.Errorf("Expected no validation error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndStore(t *testing.T) {
|
||||
Clear()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with valid config
|
||||
validConfig := &Config{
|
||||
ServiceName: "test-service",
|
||||
Endpoint: "localhost:4317",
|
||||
}
|
||||
|
||||
err := ValidateAndStore(ctx, validConfig, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Errorf("ValidateAndStore with valid config should not error: %v", err)
|
||||
}
|
||||
|
||||
if !IsConfigured() {
|
||||
t.Error("Should be configured after ValidateAndStore")
|
||||
}
|
||||
|
||||
Clear()
|
||||
|
||||
// Test with invalid config
|
||||
invalidConfig := &Config{
|
||||
ServiceName: "test-service",
|
||||
Endpoint: "localhost:4317",
|
||||
EndpointURL: "https://example.com:4317", // both specified - invalid
|
||||
}
|
||||
|
||||
err = ValidateAndStore(ctx, invalidConfig, nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Error("ValidateAndStore with invalid config should return error")
|
||||
}
|
||||
|
||||
if IsConfigured() {
|
||||
t.Error("Should not be configured after failed ValidateAndStore")
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user