diff --git a/pkg/utils/otel.go b/pkg/utils/otel.go index 4a08b9c..325e670 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 { @@ -281,6 +288,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 @@ -289,7 +311,7 @@ func newTraceProvider(ctx context.Context, cfg OTelConfig, res *resource.Resourc if cfg.Protocol == OTelProtocolHTTP { var 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()) @@ -298,7 +320,7 @@ func newTraceProvider(ctx context.Context, cfg OTelConfig, res *resource.Resourc } else { var 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()) @@ -328,7 +350,7 @@ func newMetricsProvider(ctx context.Context, cfg OTelConfig, res *resource.Resou if cfg.Protocol == OTelProtocolHTTP { var 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()) @@ -337,7 +359,7 @@ func newMetricsProvider(ctx context.Context, cfg OTelConfig, res *resource.Resou } else { var 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()) @@ -366,7 +388,7 @@ func newLoggerProvider(ctx context.Context, cfg OTelConfig, res *resource.Resour if cfg.Protocol == OTelProtocolHTTP { var 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()) @@ -375,7 +397,7 @@ func newLoggerProvider(ctx context.Context, cfg OTelConfig, res *resource.Resour } else { var 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 a76f269..6155ed1 100644 --- a/pkg/utils/otel_test.go +++ b/pkg/utils/otel_test.go @@ -5,6 +5,7 @@ package utils import ( "context" + "testing" ) @@ -423,3 +424,98 @@ func TestOTelConfig_MinimalConfig(t *testing.T) { t.Errorf("shutdown returned unexpected error: %v", err) } } + +// 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) +}