diff --git a/pkg/utils/otel.go b/pkg/utils/otel.go index b32b9a8..97f81cf 100644 --- a/pkg/utils/otel.go +++ b/pkg/utils/otel.go @@ -188,6 +188,13 @@ func SetupOTelSDKWithConfig(ctx context.Context, cfg OTelConfig) (shutdown func( err = errors.Join(inErr, shutdown(ctx)) } + // Normalize endpoint to include a URL scheme so WithEndpointURL can + // parse it. Bare IP:port values like "127.0.0.1:4317" cause url.Parse + // to fail with "first path segment in URL cannot contain colon". + if cfg.Endpoint != "" { + cfg.Endpoint = endpointURL(cfg.Endpoint, cfg.Insecure) + } + // Create resource with service information. res, err := newResource(cfg) if err != nil { @@ -276,6 +283,21 @@ func newPropagator(cfg OTelConfig) propagation.TextMapPropagator { return propagation.NewCompositeTextMapPropagator(propagators...) } +// endpointURL ensures the endpoint has a URL scheme. The OTel SDK internally +// reads OTEL_EXPORTER_OTLP_ENDPOINT and parses it with url.Parse, which fails +// for bare IP:port values like "127.0.0.1:4317" with "first path segment in +// URL cannot contain colon". Prepending a scheme based on the insecure flag +// produces a valid URL the SDK can parse. +func endpointURL(raw string, insecure bool) string { + if strings.Contains(raw, "://") { + return raw + } + if insecure { + return "http://" + raw + } + return "https://" + raw +} + // newTraceProvider creates a TracerProvider with an OTLP exporter configured based on the protocol setting. func newTraceProvider(ctx context.Context, cfg OTelConfig, res *resource.Resource) (*trace.TracerProvider, error) { var exporter trace.SpanExporter @@ -284,7 +306,7 @@ func newTraceProvider(ctx context.Context, cfg OTelConfig, res *resource.Resourc if cfg.Protocol == OTelProtocolHTTP { opts := []otlptracehttp.Option{} if cfg.Endpoint != "" { - opts = append(opts, otlptracehttp.WithEndpoint(cfg.Endpoint)) + opts = append(opts, otlptracehttp.WithEndpointURL(cfg.Endpoint)) } if cfg.Insecure { opts = append(opts, otlptracehttp.WithInsecure()) @@ -293,7 +315,7 @@ func newTraceProvider(ctx context.Context, cfg OTelConfig, res *resource.Resourc } else { opts := []otlptracegrpc.Option{} if cfg.Endpoint != "" { - opts = append(opts, otlptracegrpc.WithEndpoint(cfg.Endpoint)) + opts = append(opts, otlptracegrpc.WithEndpointURL(cfg.Endpoint)) } if cfg.Insecure { opts = append(opts, otlptracegrpc.WithInsecure()) @@ -323,7 +345,7 @@ func newMetricsProvider(ctx context.Context, cfg OTelConfig, res *resource.Resou if cfg.Protocol == OTelProtocolHTTP { opts := []otlpmetrichttp.Option{} if cfg.Endpoint != "" { - opts = append(opts, otlpmetrichttp.WithEndpoint(cfg.Endpoint)) + opts = append(opts, otlpmetrichttp.WithEndpointURL(cfg.Endpoint)) } if cfg.Insecure { opts = append(opts, otlpmetrichttp.WithInsecure()) @@ -332,7 +354,7 @@ func newMetricsProvider(ctx context.Context, cfg OTelConfig, res *resource.Resou } else { opts := []otlpmetricgrpc.Option{} if cfg.Endpoint != "" { - opts = append(opts, otlpmetricgrpc.WithEndpoint(cfg.Endpoint)) + opts = append(opts, otlpmetricgrpc.WithEndpointURL(cfg.Endpoint)) } if cfg.Insecure { opts = append(opts, otlpmetricgrpc.WithInsecure()) @@ -361,7 +383,7 @@ func newLoggerProvider(ctx context.Context, cfg OTelConfig, res *resource.Resour if cfg.Protocol == OTelProtocolHTTP { opts := []otlploghttp.Option{} if cfg.Endpoint != "" { - opts = append(opts, otlploghttp.WithEndpoint(cfg.Endpoint)) + opts = append(opts, otlploghttp.WithEndpointURL(cfg.Endpoint)) } if cfg.Insecure { opts = append(opts, otlploghttp.WithInsecure()) @@ -370,7 +392,7 @@ func newLoggerProvider(ctx context.Context, cfg OTelConfig, res *resource.Resour } else { opts := []otlploggrpc.Option{} if cfg.Endpoint != "" { - opts = append(opts, otlploggrpc.WithEndpoint(cfg.Endpoint)) + opts = append(opts, otlploggrpc.WithEndpointURL(cfg.Endpoint)) } if cfg.Insecure { opts = append(opts, otlploggrpc.WithInsecure()) diff --git a/pkg/utils/otel_test.go b/pkg/utils/otel_test.go index c9417cc..9104b14 100644 --- a/pkg/utils/otel_test.go +++ b/pkg/utils/otel_test.go @@ -5,6 +5,7 @@ package utils import ( "context" + "testing" ) @@ -225,3 +226,98 @@ func TestOTelConstants(t *testing.T) { t.Errorf("expected OTelExporterNone to be 'none', got %q", OTelExporterNone) } } + +// TestEndpointURL verifies that endpointURL prepends the correct scheme +// when missing and preserves existing schemes. +func TestEndpointURL(t *testing.T) { + tests := []struct { + name string + raw string + insecure bool + want string + }{ + { + name: "IP:port insecure", + raw: "127.0.0.1:4317", + insecure: true, + want: "http://127.0.0.1:4317", + }, + { + name: "IP:port secure", + raw: "127.0.0.1:4317", + insecure: false, + want: "https://127.0.0.1:4317", + }, + { + name: "localhost:port insecure", + raw: "localhost:4317", + insecure: true, + want: "http://localhost:4317", + }, + { + name: "hostname without port", + raw: "collector", + insecure: true, + want: "http://collector", + }, + { + name: "http URL preserved", + raw: "http://collector.example.com:4318", + insecure: false, + want: "http://collector.example.com:4318", + }, + { + name: "https URL preserved", + raw: "https://collector.example.com:4318", + insecure: true, + want: "https://collector.example.com:4318", + }, + { + name: "https URL with path preserved", + raw: "https://collector.example.com:4318/v1/traces", + insecure: false, + want: "https://collector.example.com:4318/v1/traces", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := endpointURL(tt.raw, tt.insecure) + if got != tt.want { + t.Errorf("endpointURL(%q, %t) = %q, want %q", tt.raw, tt.insecure, got, tt.want) + } + }) + } +} + +// TestSetupOTelSDKWithConfig_IPEndpoint verifies that SetupOTelSDKWithConfig +// normalizes a bare IP:port endpoint to include a scheme, preventing the +// "first path segment in URL cannot contain colon" error from the SDK. +func TestSetupOTelSDKWithConfig_IPEndpoint(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "127.0.0.1:4317") + + cfg := OTelConfig{ + ServiceName: "test-service", + ServiceVersion: "1.0.0", + Protocol: OTelProtocolGRPC, + Endpoint: "127.0.0.1:4317", + Insecure: true, + TracesExporter: OTelExporterOTLP, + TracesSampleRatio: 1.0, + MetricsExporter: OTelExporterNone, + LogsExporter: OTelExporterNone, + Propagators: "tracecontext,baggage", + } + + ctx := context.Background() + shutdown, err := SetupOTelSDKWithConfig(ctx, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if shutdown == nil { + t.Fatal("expected non-nil shutdown function") + } + + _ = shutdown(ctx) +}