diff --git a/exporters/otlp/otlptrace/otlptracegrpc/internal/gen.go b/exporters/otlp/otlptrace/otlptracegrpc/internal/gen.go index e17e143f8..0a2eb3aea 100644 --- a/exporters/otlp/otlptrace/otlptracegrpc/internal/gen.go +++ b/exporters/otlp/otlptrace/otlptracegrpc/internal/gen.go @@ -26,3 +26,6 @@ package internal // import "go.opentelemetry.io/otel/exporters/otlp/otlptrace/ot //go:generate gotmpl --body=../../../../../internal/shared/x/x.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc\" }" --out=x/x.go //go:generate gotmpl --body=../../../../../internal/shared/x/x_test.go.tmpl "--data={}" --out=x/x_test.go + +//go:generate gotmpl --body=../../../../../internal/shared/otlp/observ/target.go.tmpl "--data={ \"pkg\": \"observ\", \"pkg_path\": \"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc/internal/observ\" }" --out=observ/target.go +//go:generate gotmpl --body=../../../../../internal/shared/otlp/observ/target_test.go.tmpl "--data={ \"pkg\": \"observ\" }" --out=observ/target_test.go diff --git a/exporters/otlp/otlptrace/otlptracegrpc/internal/observ/doc.go b/exporters/otlp/otlptrace/otlptracegrpc/internal/observ/doc.go new file mode 100644 index 000000000..0dd54e4b9 --- /dev/null +++ b/exporters/otlp/otlptrace/otlptracegrpc/internal/observ/doc.go @@ -0,0 +1,6 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package observ provides experimental observability instrumentation for the +// otlptracegrpc exporter. +package observ // import "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc/internal/observ" diff --git a/exporters/otlp/otlptrace/otlptracegrpc/internal/observ/target.go b/exporters/otlp/otlptrace/otlptracegrpc/internal/observ/target.go new file mode 100644 index 000000000..34eee27db --- /dev/null +++ b/exporters/otlp/otlptrace/otlptracegrpc/internal/observ/target.go @@ -0,0 +1,143 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/otlp/observ/target.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package observ // import "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc/internal/observ" + +import ( + "errors" + "fmt" + "net" + "net/netip" + "strconv" + "strings" +) + +const ( + schemeUnix = "unix" + schemeUnixAbstract = "unix-abstract" +) + +// ParseCanonicalTarget parses a target string and returns the extracted host +// (domain address or IP), the target port, or an error. +// +// If no port is specified, -1 is returned. +// +// If no host is specified, an empty string is returned. +// +// The target string is expected to always have the form +// "://[authority]/". For example: +// - "dns:///example.com:42" +// - "dns://8.8.8.8/example.com:42" +// - "unix:///path/to/socket" +// - "unix-abstract:///socket-name" +// - "passthrough:///192.34.2.1:42" +// +// The target is expected to come from the CanonicalTarget method of a gRPC +// Client. +func ParseCanonicalTarget(target string) (string, int, error) { + const sep = "://" + + // Find scheme. Do not allocate the string by using url.Parse. + idx := strings.Index(target, sep) + if idx == -1 { + return "", -1, fmt.Errorf("invalid target %q: missing scheme", target) + } + scheme, endpoint := target[:idx], target[idx+len(sep):] + + // Check for unix schemes. + if scheme == schemeUnix || scheme == schemeUnixAbstract { + return parseUnix(endpoint) + } + + // Strip leading slash and any authority. + if i := strings.Index(endpoint, "/"); i != -1 { + endpoint = endpoint[i+1:] + } + + // DNS, passthrough, and custom resolvers. + return parseEndpoint(endpoint) +} + +// parseUnix parses unix socket targets. +func parseUnix(endpoint string) (string, int, error) { + // Format: unix[-abstract]://path + // + // We should have "/path" (empty authority) if valid. + if len(endpoint) >= 1 && endpoint[0] == '/' { + // Return the full path including leading slash. + return endpoint, -1, nil + } + + // If there's no leading slash, it means there might be an authority + // Check for authority case (should error): "authority/path" + if slashIdx := strings.Index(endpoint, "/"); slashIdx > 0 { + return "", -1, fmt.Errorf("invalid (non-empty) authority: %s", endpoint[:slashIdx]) + } + + return "", -1, errors.New("invalid unix target format") +} + +// parseEndpoint parses an endpoint from a gRPC target. +// +// It supports the following formats: +// - "host" +// - "host%zone" +// - "host:port" +// - "host%zone:port" +// - "ipv4" +// - "ipv4%zone" +// - "ipv4:port" +// - "ipv4%zone:port" +// - "ipv6" +// - "ipv6%zone" +// - "[ipv6]" +// - "[ipv6%zone]" +// - "[ipv6]:port" +// - "[ipv6%zone]:port" +// +// It returns the host or host%zone (domain address or IP), the port (or -1 if +// not specified), or an error if the input is not a valid. +func parseEndpoint(endpoint string) (string, int, error) { + // First check if the endpoint is just an IP address. + if ip := parseIP(endpoint); ip != "" { + return ip, -1, nil + } + + // If there's no colon, there is no port (IPv6 with no port checked above). + if !strings.Contains(endpoint, ":") { + return endpoint, -1, nil + } + + host, portStr, err := net.SplitHostPort(endpoint) + if err != nil { + return "", -1, fmt.Errorf("invalid host:port %q: %w", endpoint, err) + } + + const base, bitSize = 10, 16 + port16, err := strconv.ParseUint(portStr, base, bitSize) + if err != nil { + return "", -1, fmt.Errorf("invalid port %q: %w", portStr, err) + } + port := int(port16) // port is guaranteed to be in the range [0, 65535]. + + return host, port, nil +} + +// parseIP attempts to parse the entire endpoint as an IP address. +// It returns the normalized string form of the IP if successful, +// or an empty string if parsing fails. +func parseIP(ip string) string { + // Strip leading and trailing brackets for IPv6 addresses. + if len(ip) >= 2 && ip[0] == '[' && ip[len(ip)-1] == ']' { + ip = ip[1 : len(ip)-1] + } + addr, err := netip.ParseAddr(ip) + if err != nil { + return "" + } + // Return the normalized string form of the IP. + return addr.String() +} diff --git a/exporters/otlp/otlptrace/otlptracegrpc/internal/observ/target_test.go b/exporters/otlp/otlptrace/otlptracegrpc/internal/observ/target_test.go new file mode 100644 index 000000000..27fa8239f --- /dev/null +++ b/exporters/otlp/otlptrace/otlptracegrpc/internal/observ/target_test.go @@ -0,0 +1,162 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/otlp/observ/target_test.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package observ + +import "testing" + +func TestParseTarget(t *testing.T) { + // gRPC target naming is defined here: + // https://github.com/grpc/grpc/blob/74232c6bd3c0f4bc35bad035dbeecf5cbc834a11/doc/naming.md + // + // The Go gRPC client only supports the "dns", "unix", "unix-abstract", and + // "passthrough" schemes natively with "dns" being the default: + // https://pkg.go.dev/google.golang.org/grpc@v1.75.1/internal/resolver + // + // Other schemes (e.g., "consul", "zk") are supported via custom resolvers + // that can be registered with the gRPC resolver package. These custom + // resolvers are still expected to follow the general target string format + // when rendered with the CanonicalTarget method: + // + // :/// + // + // All target strings in these tests are rendered with the + // CanonicalTarget method. Therefore they all follow the above format. + tests := []struct { + target string + host string + port int + }{ + // DNS scheme: hostname and port. + {target: "dns:///:8080", host: "", port: 8080}, + {target: "dns:///example.com", host: "example.com", port: -1}, + {target: "dns:///example.com%eth0", host: "example.com%eth0", port: -1}, + {target: "dns:///example.com:42", host: "example.com", port: 42}, + {target: "dns:///example.com%eth0:42", host: "example.com%eth0", port: 42}, + + // DNS scheme: hostname and port with authority. + {target: "dns://8.8.8.8/example.com", host: "example.com", port: -1}, + {target: "dns://8.8.8.8/example.com%eth0", host: "example.com%eth0", port: -1}, + {target: "dns://8.8.8.8/example.com:42", host: "example.com", port: 42}, + {target: "dns://8.8.8.8/example.com%eth0:42", host: "example.com%eth0", port: 42}, + + // DNS scheme: IPv4 address and port. + {target: "dns:///192.168.1.1", host: "192.168.1.1", port: -1}, + {target: "dns:///192.168.1.1%eth0", host: "192.168.1.1%eth0", port: -1}, + {target: "dns:///192.168.1.1:8080", host: "192.168.1.1", port: 8080}, + {target: "dns:///192.168.1.1%eth0:8080", host: "192.168.1.1%eth0", port: 8080}, + + // DNS scheme: IPv6 address and port. + {target: "dns:///2001:0db8:85a3:0000:0000:8a2e:0370:7334", host: "2001:db8:85a3::8a2e:370:7334", port: -1}, + {target: "dns:///2001:db8:85a3:0:0:8a2e:370:7334", host: "2001:db8:85a3::8a2e:370:7334", port: -1}, + {target: "dns:///2001:db8:85a3::8a2e:370:7334", host: "2001:db8:85a3::8a2e:370:7334", port: -1}, + {target: "dns:///2001:db8:85a3::8a2e:370:7334%eth0", host: "2001:db8:85a3::8a2e:370:7334%eth0", port: -1}, + {target: "dns:///[2001:db8:85a3::8a2e:370:7334]", host: "2001:db8:85a3::8a2e:370:7334", port: -1}, + {target: "dns:///[2001:db8:85a3::8a2e:370:7334%eth0]", host: "2001:db8:85a3::8a2e:370:7334%eth0", port: -1}, + {target: "dns:///[::1]:9090", host: "::1", port: 9090}, + {target: "dns:///[::1%eth0]:9090", host: "::1%eth0", port: 9090}, + + // Unix domain sockets. + {target: "unix:///tmp/grpc.sock", host: "/tmp/grpc.sock", port: -1}, + {target: "unix:///absolute_path", host: "/absolute_path", port: -1}, + + // Unix domain socket in abstract namespace. + {target: "unix-abstract:///abstract-socket-name", host: "/abstract-socket-name", port: -1}, + + // International domain names. + {target: "dns:///测试.example.com:8080", host: "测试.example.com", port: 8080}, + + // Port edge cases. + {target: "dns:///example.com:0", host: "example.com", port: 0}, + {target: "dns:///example.com:65535", host: "example.com", port: 65535}, + + // Case sensitivity. + {target: "dns:///EXAMPLE.COM:8080", host: "EXAMPLE.COM", port: 8080}, + {target: "dns:///Example.Com:8080", host: "Example.Com", port: 8080}, + + // Custom and passthrough resolvers scheme + {target: "passthrough:///localhost:50051", host: "localhost", port: 50051}, + {target: "passthrough:///10.0.0.2:7777", host: "10.0.0.2", port: 7777}, + {target: "consul:///my-service", host: "my-service", port: -1}, + {target: "zk:///services/my-service", host: "services/my-service", port: -1}, + } + + for _, tt := range tests { + host, port, err := ParseCanonicalTarget(tt.target) + if err != nil { + t.Errorf("parseTarget(%q) unexpected error: %v", tt.target, err) + continue + } + if host != tt.host { + t.Errorf("parseTarget(%q) host = %q, want %q", tt.target, host, tt.host) + } + if port != tt.port { + t.Errorf("parseTarget(%q) port = %d, want %d", tt.target, port, tt.port) + } + } +} + +func TestParseTargetErrors(t *testing.T) { + targets := []string{ + "dns:///example.com:invalid", // Non-numeric port in URL. + "dns:///example.com:8080:9090", // Multiple colons in port. + "dns:///example.com:99999", // Port out of range. + "dns:///example.com:-1", // Port out of range. + "unix://localhost/sock", // Non-empty authority for unix scheme. + "unix:", // Empty unix scheme. + "unix-abstract://", // Empty unix-abstract scheme. + "unix-abstract://authority/sock", // Non-empty authority for unix-abstract scheme. + "contains-cont\roll-cha\rs", // Invalid URL. + } + + for _, target := range targets { + host, port, err := ParseCanonicalTarget(target) + if err == nil { + t.Errorf("parseTarget(%q) expected error, got nil", target) + } + + if host != "" { + t.Errorf("parseTarget(%q) host = %q, want empty", target, host) + } + + if port != -1 { + t.Errorf("parseTarget(%q) port = %d, want -1", target, port) + } + } +} + +func BenchmarkParseTarget(b *testing.B) { + benchmarks := []struct { + name string + target string + }{ + {"HostName", "dns:///example.com"}, + {"HostPort", "dns:///example.com:8080"}, + {"IPv4WithoutPort", "dns:///192.168.1.1"}, + {"IPv4WithPort", "dns:///192.168.1.1:8080"}, + {"IPv6Bare", "dns:///2001:db8::1"}, + {"IPv6Bracket", "dns:///[2001:db8::1]"}, + {"IPv6WithPort", "dns:///[2001:db8::1]:8080"}, + {"UnixSocket", "unix:///tmp/grpc.sock"}, + {"UnixAbstractSocket", "unix-abstract:///abstract-socket-name"}, + {"Passthrough", "passthrough:///localhost:50051"}, + } + + var ( + host string + port int + err error + ) + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + host, port, err = ParseCanonicalTarget(bm.target) + } + }) + } + _, _, _ = host, port, err +} diff --git a/internal/shared/otlp/observ/target.go.tmpl b/internal/shared/otlp/observ/target.go.tmpl new file mode 100644 index 000000000..bbc5f2acb --- /dev/null +++ b/internal/shared/otlp/observ/target.go.tmpl @@ -0,0 +1,143 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/otlp/observ/target.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package {{ .pkg }} // import "{{ .pkg_path }}" + +import ( + "errors" + "fmt" + "net" + "net/netip" + "strconv" + "strings" +) + +const ( + schemeUnix = "unix" + schemeUnixAbstract = "unix-abstract" +) + +// ParseCanonicalTarget parses a target string and returns the extracted host +// (domain address or IP), the target port, or an error. +// +// If no port is specified, -1 is returned. +// +// If no host is specified, an empty string is returned. +// +// The target string is expected to always have the form +// "://[authority]/". For example: +// - "dns:///example.com:42" +// - "dns://8.8.8.8/example.com:42" +// - "unix:///path/to/socket" +// - "unix-abstract:///socket-name" +// - "passthrough:///192.34.2.1:42" +// +// The target is expected to come from the CanonicalTarget method of a gRPC +// Client. +func ParseCanonicalTarget(target string) (string, int, error) { + const sep = "://" + + // Find scheme. Do not allocate the string by using url.Parse. + idx := strings.Index(target, sep) + if idx == -1 { + return "", -1, fmt.Errorf("invalid target %q: missing scheme", target) + } + scheme, endpoint := target[:idx], target[idx+len(sep):] + + // Check for unix schemes. + if scheme == schemeUnix || scheme == schemeUnixAbstract { + return parseUnix(endpoint) + } + + // Strip leading slash and any authority. + if i := strings.Index(endpoint, "/"); i != -1 { + endpoint = endpoint[i+1:] + } + + // DNS, passthrough, and custom resolvers. + return parseEndpoint(endpoint) +} + +// parseUnix parses unix socket targets. +func parseUnix(endpoint string) (string, int, error) { + // Format: unix[-abstract]://path + // + // We should have "/path" (empty authority) if valid. + if len(endpoint) >= 1 && endpoint[0] == '/' { + // Return the full path including leading slash. + return endpoint, -1, nil + } + + // If there's no leading slash, it means there might be an authority + // Check for authority case (should error): "authority/path" + if slashIdx := strings.Index(endpoint, "/"); slashIdx > 0 { + return "", -1, fmt.Errorf("invalid (non-empty) authority: %s", endpoint[:slashIdx]) + } + + return "", -1, errors.New("invalid unix target format") +} + +// parseEndpoint parses an endpoint from a gRPC target. +// +// It supports the following formats: +// - "host" +// - "host%zone" +// - "host:port" +// - "host%zone:port" +// - "ipv4" +// - "ipv4%zone" +// - "ipv4:port" +// - "ipv4%zone:port" +// - "ipv6" +// - "ipv6%zone" +// - "[ipv6]" +// - "[ipv6%zone]" +// - "[ipv6]:port" +// - "[ipv6%zone]:port" +// +// It returns the host or host%zone (domain address or IP), the port (or -1 if +// not specified), or an error if the input is not a valid. +func parseEndpoint(endpoint string) (string, int, error) { + // First check if the endpoint is just an IP address. + if ip := parseIP(endpoint); ip != "" { + return ip, -1, nil + } + + // If there's no colon, there is no port (IPv6 with no port checked above). + if !strings.Contains(endpoint, ":") { + return endpoint, -1, nil + } + + host, portStr, err := net.SplitHostPort(endpoint) + if err != nil { + return "", -1, fmt.Errorf("invalid host:port %q: %w", endpoint, err) + } + + const base, bitSize = 10, 16 + port16, err := strconv.ParseUint(portStr, base, bitSize) + if err != nil { + return "", -1, fmt.Errorf("invalid port %q: %w", portStr, err) + } + port := int(port16) // port is guaranteed to be in the range [0, 65535]. + + return host, port, nil +} + +// parseIP attempts to parse the entire endpoint as an IP address. +// It returns the normalized string form of the IP if successful, +// or an empty string if parsing fails. +func parseIP(ip string) string { + // Strip leading and trailing brackets for IPv6 addresses. + if len(ip) >= 2 && ip[0] == '[' && ip[len(ip)-1] == ']' { + ip = ip[1 : len(ip)-1] + } + addr, err := netip.ParseAddr(ip) + if err != nil { + return "" + } + // Return the normalized string form of the IP. + return addr.String() +} diff --git a/internal/shared/otlp/observ/target_test.go.tmpl b/internal/shared/otlp/observ/target_test.go.tmpl new file mode 100644 index 000000000..b7faa9087 --- /dev/null +++ b/internal/shared/otlp/observ/target_test.go.tmpl @@ -0,0 +1,162 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/otlp/observ/target_test.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package {{ .pkg }} + +import "testing" + +func TestParseTarget(t *testing.T) { + // gRPC target naming is defined here: + // https://github.com/grpc/grpc/blob/74232c6bd3c0f4bc35bad035dbeecf5cbc834a11/doc/naming.md + // + // The Go gRPC client only supports the "dns", "unix", "unix-abstract", and + // "passthrough" schemes natively with "dns" being the default: + // https://pkg.go.dev/google.golang.org/grpc@v1.75.1/internal/resolver + // + // Other schemes (e.g., "consul", "zk") are supported via custom resolvers + // that can be registered with the gRPC resolver package. These custom + // resolvers are still expected to follow the general target string format + // when rendered with the CanonicalTarget method: + // + // :/// + // + // All target strings in these tests are rendered with the + // CanonicalTarget method. Therefore they all follow the above format. + tests := []struct { + target string + host string + port int + }{ + // DNS scheme: hostname and port. + {target: "dns:///:8080", host: "", port: 8080}, + {target: "dns:///example.com", host: "example.com", port: -1}, + {target: "dns:///example.com%eth0", host: "example.com%eth0", port: -1}, + {target: "dns:///example.com:42", host: "example.com", port: 42}, + {target: "dns:///example.com%eth0:42", host: "example.com%eth0", port: 42}, + + // DNS scheme: hostname and port with authority. + {target: "dns://8.8.8.8/example.com", host: "example.com", port: -1}, + {target: "dns://8.8.8.8/example.com%eth0", host: "example.com%eth0", port: -1}, + {target: "dns://8.8.8.8/example.com:42", host: "example.com", port: 42}, + {target: "dns://8.8.8.8/example.com%eth0:42", host: "example.com%eth0", port: 42}, + + // DNS scheme: IPv4 address and port. + {target: "dns:///192.168.1.1", host: "192.168.1.1", port: -1}, + {target: "dns:///192.168.1.1%eth0", host: "192.168.1.1%eth0", port: -1}, + {target: "dns:///192.168.1.1:8080", host: "192.168.1.1", port: 8080}, + {target: "dns:///192.168.1.1%eth0:8080", host: "192.168.1.1%eth0", port: 8080}, + + // DNS scheme: IPv6 address and port. + {target: "dns:///2001:0db8:85a3:0000:0000:8a2e:0370:7334", host: "2001:db8:85a3::8a2e:370:7334", port: -1}, + {target: "dns:///2001:db8:85a3:0:0:8a2e:370:7334", host: "2001:db8:85a3::8a2e:370:7334", port: -1}, + {target: "dns:///2001:db8:85a3::8a2e:370:7334", host: "2001:db8:85a3::8a2e:370:7334", port: -1}, + {target: "dns:///2001:db8:85a3::8a2e:370:7334%eth0", host: "2001:db8:85a3::8a2e:370:7334%eth0", port: -1}, + {target: "dns:///[2001:db8:85a3::8a2e:370:7334]", host: "2001:db8:85a3::8a2e:370:7334", port: -1}, + {target: "dns:///[2001:db8:85a3::8a2e:370:7334%eth0]", host: "2001:db8:85a3::8a2e:370:7334%eth0", port: -1}, + {target: "dns:///[::1]:9090", host: "::1", port: 9090}, + {target: "dns:///[::1%eth0]:9090", host: "::1%eth0", port: 9090}, + + // Unix domain sockets. + {target: "unix:///tmp/grpc.sock", host: "/tmp/grpc.sock", port: -1}, + {target: "unix:///absolute_path", host: "/absolute_path", port: -1}, + + // Unix domain socket in abstract namespace. + {target: "unix-abstract:///abstract-socket-name", host: "/abstract-socket-name", port: -1}, + + // International domain names. + {target: "dns:///测试.example.com:8080", host: "测试.example.com", port: 8080}, + + // Port edge cases. + {target: "dns:///example.com:0", host: "example.com", port: 0}, + {target: "dns:///example.com:65535", host: "example.com", port: 65535}, + + // Case sensitivity. + {target: "dns:///EXAMPLE.COM:8080", host: "EXAMPLE.COM", port: 8080}, + {target: "dns:///Example.Com:8080", host: "Example.Com", port: 8080}, + + // Custom and passthrough resolvers scheme + {target: "passthrough:///localhost:50051", host: "localhost", port: 50051}, + {target: "passthrough:///10.0.0.2:7777", host: "10.0.0.2", port: 7777}, + {target: "consul:///my-service", host: "my-service", port: -1}, + {target: "zk:///services/my-service", host: "services/my-service", port: -1}, + } + + for _, tt := range tests { + host, port, err := ParseCanonicalTarget(tt.target) + if err != nil { + t.Errorf("parseTarget(%q) unexpected error: %v", tt.target, err) + continue + } + if host != tt.host { + t.Errorf("parseTarget(%q) host = %q, want %q", tt.target, host, tt.host) + } + if port != tt.port { + t.Errorf("parseTarget(%q) port = %d, want %d", tt.target, port, tt.port) + } + } +} + +func TestParseTargetErrors(t *testing.T) { + targets := []string{ + "dns:///example.com:invalid", // Non-numeric port in URL. + "dns:///example.com:8080:9090", // Multiple colons in port. + "dns:///example.com:99999", // Port out of range. + "dns:///example.com:-1", // Port out of range. + "unix://localhost/sock", // Non-empty authority for unix scheme. + "unix:", // Empty unix scheme. + "unix-abstract://", // Empty unix-abstract scheme. + "unix-abstract://authority/sock", // Non-empty authority for unix-abstract scheme. + "contains-cont\roll-cha\rs", // Invalid URL. + } + + for _, target := range targets { + host, port, err := ParseCanonicalTarget(target) + if err == nil { + t.Errorf("parseTarget(%q) expected error, got nil", target) + } + + if host != "" { + t.Errorf("parseTarget(%q) host = %q, want empty", target, host) + } + + if port != -1 { + t.Errorf("parseTarget(%q) port = %d, want -1", target, port) + } + } +} + +func BenchmarkParseTarget(b *testing.B) { + benchmarks := []struct { + name string + target string + }{ + {"HostName", "dns:///example.com"}, + {"HostPort", "dns:///example.com:8080"}, + {"IPv4WithoutPort", "dns:///192.168.1.1"}, + {"IPv4WithPort", "dns:///192.168.1.1:8080"}, + {"IPv6Bare", "dns:///2001:db8::1"}, + {"IPv6Bracket", "dns:///[2001:db8::1]"}, + {"IPv6WithPort", "dns:///[2001:db8::1]:8080"}, + {"UnixSocket", "unix:///tmp/grpc.sock"}, + {"UnixAbstractSocket", "unix-abstract:///abstract-socket-name"}, + {"Passthrough", "passthrough:///localhost:50051"}, + } + + var ( + host string + port int + err error + ) + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + host, port, err = ParseCanonicalTarget(bm.target) + } + }) + } + _, _, _ = host, port, err +}