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:
@@ -7,11 +7,103 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
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/credentials"
|
||||
)
|
||||
|
||||
const (
|
||||
otelExporterOTLPProtoEnvKey = "OTEL_EXPORTER_OTLP_PROTOCOL"
|
||||
otelExporterOTLPTracesProtoEnvKey = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"
|
||||
otelExporterOTLPLogsProtoEnvKey = "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL"
|
||||
otelExporterOTLPMetricsProtoEnvKey = "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"
|
||||
)
|
||||
|
||||
var errInvalidOTLPProtocol = errors.New("invalid OTLP protocol - should be one of ['grpc', 'http/protobuf']")
|
||||
|
||||
// newInvalidProtocolError creates a specific error message for invalid protocols
|
||||
func newInvalidProtocolError(protocol, signalType string) error {
|
||||
return fmt.Errorf("invalid OTLP protocol '%s' for %s - should be one of ['grpc', 'http/protobuf', 'http/json']", protocol, signalType)
|
||||
}
|
||||
|
||||
// Validate checks the configuration for common errors and inconsistencies
|
||||
func (c *Config) Validate() error {
|
||||
var errs []error
|
||||
|
||||
// Check that both Endpoint and EndpointURL are not specified
|
||||
if c.Endpoint != "" && c.EndpointURL != "" {
|
||||
errs = append(errs, errors.New("cannot specify both Endpoint and EndpointURL - use one or the other"))
|
||||
}
|
||||
|
||||
// Validate EndpointURL format if specified
|
||||
if c.EndpointURL != "" {
|
||||
if _, err := url.Parse(c.EndpointURL); err != nil {
|
||||
errs = append(errs, fmt.Errorf("invalid EndpointURL format: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Endpoint format if specified
|
||||
if c.Endpoint != "" {
|
||||
// Basic validation - should not contain protocol scheme
|
||||
if strings.Contains(c.Endpoint, "://") {
|
||||
errs = append(errs, errors.New("Endpoint should not include protocol scheme (use EndpointURL for full URLs)"))
|
||||
}
|
||||
// Should not be empty after trimming whitespace
|
||||
if strings.TrimSpace(c.Endpoint) == "" {
|
||||
errs = append(errs, errors.New("Endpoint cannot be empty or whitespace"))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate TLS configuration consistency
|
||||
if c.CertificateProvider != nil && c.RootCAs == nil {
|
||||
// This is just a warning - client cert without custom CAs is valid
|
||||
// but might indicate a configuration issue
|
||||
}
|
||||
|
||||
// Validate service name if specified
|
||||
if c.ServiceName != "" && strings.TrimSpace(c.ServiceName) == "" {
|
||||
errs = append(errs, errors.New("ServiceName cannot be empty or whitespace"))
|
||||
}
|
||||
|
||||
// Combine all errors
|
||||
if len(errs) > 0 {
|
||||
var errMsgs []string
|
||||
for _, err := range errs {
|
||||
errMsgs = append(errMsgs, err.Error())
|
||||
}
|
||||
return fmt.Errorf("configuration validation failed: %s", strings.Join(errMsgs, "; "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAndStore validates the configuration before storing it
|
||||
func ValidateAndStore(ctx context.Context, cfg *Config, logFactory LogExporterFactory, metricFactory MetricExporterFactory, traceFactory TraceExporterFactory) error {
|
||||
if cfg != nil {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
Store(ctx, cfg, logFactory, metricFactory, traceFactory)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientCertificate defines a function type for providing client certificates for mutual TLS.
|
||||
// This is used when exporting telemetry data to secured OTLP endpoints that require
|
||||
// client certificate authentication.
|
||||
@@ -29,41 +121,76 @@ type Config struct {
|
||||
RootCAs *x509.CertPool // CA certificate pool for server verification
|
||||
}
|
||||
|
||||
// ExporterFactory creates an OTLP log exporter using the provided configuration.
|
||||
// LogExporterFactory creates an OTLP log exporter using the provided configuration.
|
||||
// This allows the logger package to create exporters without importing the tracing package.
|
||||
type ExporterFactory func(context.Context, *Config) (sdklog.Exporter, error)
|
||||
type LogExporterFactory func(context.Context, *Config) (sdklog.Exporter, error)
|
||||
|
||||
// MetricExporterFactory creates an OTLP metric exporter using the provided configuration.
|
||||
// This allows the metrics package to create exporters without importing the tracing package.
|
||||
type MetricExporterFactory func(context.Context, *Config) (sdkmetric.Exporter, error)
|
||||
|
||||
// TraceExporterFactory creates an OTLP trace exporter using the provided configuration.
|
||||
// This allows for consistent trace exporter creation across packages.
|
||||
type TraceExporterFactory func(context.Context, *Config) (sdktrace.SpanExporter, error)
|
||||
|
||||
// Global state for sharing configuration between packages
|
||||
var (
|
||||
globalConfig *Config
|
||||
globalContext context.Context
|
||||
exporterFactory ExporterFactory
|
||||
configMu sync.RWMutex
|
||||
globalConfig *Config
|
||||
globalContext context.Context
|
||||
logExporterFactory LogExporterFactory
|
||||
metricExporterFactory MetricExporterFactory
|
||||
traceExporterFactory TraceExporterFactory
|
||||
configMu sync.RWMutex
|
||||
)
|
||||
|
||||
// Store saves the tracer configuration and exporter factory for use by other packages.
|
||||
// Store saves the tracer configuration and exporter factories for use by other packages.
|
||||
// This should be called by the tracing package during initialization.
|
||||
func Store(ctx context.Context, cfg *Config, factory ExporterFactory) {
|
||||
func Store(ctx context.Context, cfg *Config, logFactory LogExporterFactory, metricFactory MetricExporterFactory, traceFactory TraceExporterFactory) {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
globalConfig = cfg
|
||||
globalContext = ctx
|
||||
exporterFactory = factory
|
||||
logExporterFactory = logFactory
|
||||
metricExporterFactory = metricFactory
|
||||
traceExporterFactory = traceFactory
|
||||
}
|
||||
|
||||
// Get returns the stored tracer configuration, context, and exporter factory.
|
||||
// GetLogExporter returns the stored configuration and log exporter factory.
|
||||
// Returns nil values if no configuration has been stored yet.
|
||||
func Get() (*Config, context.Context, ExporterFactory) {
|
||||
func GetLogExporter() (*Config, context.Context, LogExporterFactory) {
|
||||
configMu.RLock()
|
||||
defer configMu.RUnlock()
|
||||
return globalConfig, globalContext, exporterFactory
|
||||
return globalConfig, globalContext, logExporterFactory
|
||||
}
|
||||
|
||||
// GetMetricExporter returns the stored configuration and metric exporter factory.
|
||||
// Returns nil values if no configuration has been stored yet.
|
||||
func GetMetricExporter() (*Config, context.Context, MetricExporterFactory) {
|
||||
configMu.RLock()
|
||||
defer configMu.RUnlock()
|
||||
return globalConfig, globalContext, metricExporterFactory
|
||||
}
|
||||
|
||||
// GetTraceExporter returns the stored configuration and trace exporter factory.
|
||||
// Returns nil values if no configuration has been stored yet.
|
||||
func GetTraceExporter() (*Config, context.Context, TraceExporterFactory) {
|
||||
configMu.RLock()
|
||||
defer configMu.RUnlock()
|
||||
return globalConfig, globalContext, traceExporterFactory
|
||||
}
|
||||
|
||||
// Get returns the stored tracer configuration, context, and log exporter factory.
|
||||
// This maintains backward compatibility for the logger package.
|
||||
// Returns nil values if no configuration has been stored yet.
|
||||
func Get() (*Config, context.Context, LogExporterFactory) {
|
||||
return GetLogExporter()
|
||||
}
|
||||
|
||||
// IsConfigured returns true if tracer configuration has been stored.
|
||||
func IsConfigured() bool {
|
||||
configMu.RLock()
|
||||
defer configMu.RUnlock()
|
||||
return globalConfig != nil && globalContext != nil && exporterFactory != nil
|
||||
return globalConfig != nil && globalContext != nil
|
||||
}
|
||||
|
||||
// Clear removes the stored configuration. This is primarily useful for testing.
|
||||
@@ -72,5 +199,180 @@ func Clear() {
|
||||
defer configMu.Unlock()
|
||||
globalConfig = nil
|
||||
globalContext = nil
|
||||
exporterFactory = nil
|
||||
logExporterFactory = nil
|
||||
metricExporterFactory = nil
|
||||
traceExporterFactory = nil
|
||||
}
|
||||
|
||||
// getTLSConfig creates a TLS configuration from the provided Config.
|
||||
func getTLSConfig(cfg *Config) *tls.Config {
|
||||
if cfg.CertificateProvider == nil {
|
||||
return nil
|
||||
}
|
||||
return &tls.Config{
|
||||
GetClientCertificate: cfg.CertificateProvider,
|
||||
RootCAs: cfg.RootCAs,
|
||||
}
|
||||
}
|
||||
|
||||
// getProtocol determines the OTLP protocol to use for the given signal type.
|
||||
// It follows OpenTelemetry environment variable precedence.
|
||||
func getProtocol(signalSpecificEnv string) string {
|
||||
proto := os.Getenv(signalSpecificEnv)
|
||||
if proto == "" {
|
||||
proto = os.Getenv(otelExporterOTLPProtoEnvKey)
|
||||
}
|
||||
// Fallback to default, http/protobuf.
|
||||
if proto == "" {
|
||||
proto = "http/protobuf"
|
||||
}
|
||||
return proto
|
||||
}
|
||||
|
||||
// CreateOTLPLogExporter creates an OTLP log exporter using the provided configuration.
|
||||
func CreateOTLPLogExporter(ctx context.Context, cfg *Config) (sdklog.Exporter, error) {
|
||||
tlsConfig := getTLSConfig(cfg)
|
||||
proto := getProtocol(otelExporterOTLPLogsProtoEnvKey)
|
||||
|
||||
switch proto {
|
||||
case "grpc":
|
||||
opts := []otlploggrpc.Option{
|
||||
otlploggrpc.WithCompressor("gzip"),
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlploggrpc.WithTLSCredentials(credentials.NewTLS(tlsConfig)))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlploggrpc.WithEndpoint(cfg.Endpoint))
|
||||
}
|
||||
if len(cfg.EndpointURL) > 0 {
|
||||
opts = append(opts, otlploggrpc.WithEndpointURL(cfg.EndpointURL))
|
||||
}
|
||||
|
||||
return otlploggrpc.New(ctx, opts...)
|
||||
case "http/protobuf", "http/json":
|
||||
opts := []otlploghttp.Option{
|
||||
otlploghttp.WithCompression(otlploghttp.GzipCompression),
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlploghttp.WithTLSClientConfig(tlsConfig))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlploghttp.WithEndpoint(cfg.Endpoint))
|
||||
}
|
||||
if len(cfg.EndpointURL) > 0 {
|
||||
opts = append(opts, otlploghttp.WithEndpointURL(cfg.EndpointURL))
|
||||
}
|
||||
|
||||
opts = append(opts, otlploghttp.WithRetry(otlploghttp.RetryConfig{
|
||||
Enabled: true,
|
||||
InitialInterval: 3 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
MaxElapsedTime: 5 * time.Minute,
|
||||
}))
|
||||
|
||||
return otlploghttp.New(ctx, opts...)
|
||||
default:
|
||||
return nil, newInvalidProtocolError(proto, "logs")
|
||||
}
|
||||
}
|
||||
|
||||
// CreateOTLPMetricExporter creates an OTLP metric exporter using the provided configuration.
|
||||
func CreateOTLPMetricExporter(ctx context.Context, cfg *Config) (sdkmetric.Exporter, error) {
|
||||
tlsConfig := getTLSConfig(cfg)
|
||||
proto := getProtocol(otelExporterOTLPMetricsProtoEnvKey)
|
||||
|
||||
switch proto {
|
||||
case "grpc":
|
||||
opts := []otlpmetricgrpc.Option{
|
||||
otlpmetricgrpc.WithCompressor("gzip"),
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlpmetricgrpc.WithTLSCredentials(credentials.NewTLS(tlsConfig)))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlpmetricgrpc.WithEndpoint(cfg.Endpoint))
|
||||
}
|
||||
if len(cfg.EndpointURL) > 0 {
|
||||
opts = append(opts, otlpmetricgrpc.WithEndpointURL(cfg.EndpointURL))
|
||||
}
|
||||
|
||||
return otlpmetricgrpc.New(ctx, opts...)
|
||||
case "http/protobuf", "http/json":
|
||||
opts := []otlpmetrichttp.Option{
|
||||
otlpmetrichttp.WithCompression(otlpmetrichttp.GzipCompression),
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlpmetrichttp.WithTLSClientConfig(tlsConfig))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlpmetrichttp.WithEndpoint(cfg.Endpoint))
|
||||
}
|
||||
if len(cfg.EndpointURL) > 0 {
|
||||
opts = append(opts, otlpmetrichttp.WithEndpointURL(cfg.EndpointURL))
|
||||
}
|
||||
|
||||
opts = append(opts, otlpmetrichttp.WithRetry(otlpmetrichttp.RetryConfig{
|
||||
Enabled: true,
|
||||
InitialInterval: 3 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
MaxElapsedTime: 5 * time.Minute,
|
||||
}))
|
||||
|
||||
return otlpmetrichttp.New(ctx, opts...)
|
||||
default:
|
||||
return nil, newInvalidProtocolError(proto, "metrics")
|
||||
}
|
||||
}
|
||||
|
||||
// CreateOTLPTraceExporter creates an OTLP trace exporter using the provided configuration.
|
||||
func CreateOTLPTraceExporter(ctx context.Context, cfg *Config) (sdktrace.SpanExporter, error) {
|
||||
tlsConfig := getTLSConfig(cfg)
|
||||
proto := getProtocol(otelExporterOTLPTracesProtoEnvKey)
|
||||
|
||||
var client otlptrace.Client
|
||||
|
||||
switch proto {
|
||||
case "grpc":
|
||||
opts := []otlptracegrpc.Option{
|
||||
otlptracegrpc.WithCompressor("gzip"),
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlptracegrpc.WithTLSCredentials(credentials.NewTLS(tlsConfig)))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlptracegrpc.WithEndpoint(cfg.Endpoint))
|
||||
}
|
||||
if len(cfg.EndpointURL) > 0 {
|
||||
opts = append(opts, otlptracegrpc.WithEndpointURL(cfg.EndpointURL))
|
||||
}
|
||||
|
||||
client = otlptracegrpc.NewClient(opts...)
|
||||
case "http/protobuf", "http/json":
|
||||
opts := []otlptracehttp.Option{
|
||||
otlptracehttp.WithCompression(otlptracehttp.GzipCompression),
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
opts = append(opts, otlptracehttp.WithTLSClientConfig(tlsConfig))
|
||||
}
|
||||
if len(cfg.Endpoint) > 0 {
|
||||
opts = append(opts, otlptracehttp.WithEndpoint(cfg.Endpoint))
|
||||
}
|
||||
if len(cfg.EndpointURL) > 0 {
|
||||
opts = append(opts, otlptracehttp.WithEndpointURL(cfg.EndpointURL))
|
||||
}
|
||||
|
||||
opts = append(opts, otlptracehttp.WithRetry(otlptracehttp.RetryConfig{
|
||||
Enabled: true,
|
||||
InitialInterval: 3 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
MaxElapsedTime: 5 * time.Minute,
|
||||
}))
|
||||
|
||||
client = otlptracehttp.NewClient(opts...)
|
||||
default:
|
||||
return nil, newInvalidProtocolError(proto, "traces")
|
||||
}
|
||||
|
||||
return otlptrace.New(ctx, client)
|
||||
}
|
||||
|
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