diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b4333441d..b90c3a4b167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ This update is a breaking change of the unstable Metrics API. Code instrumented - The metrics API has been significantly changed. (#2587) - Unify path cleaning functionally in the `otlpmetric` and `otlptrace` config. (#2639) - Change the debug message from the `sdk/trace.BatchSpanProcessor` to reflect the count is cumulative. (#2640) +- Introduce new internal envconfig package for OTLP exporters (#2608) ### Fixed diff --git a/exporters/otlp/internal/envconfig/envconfig.go b/exporters/otlp/internal/envconfig/envconfig.go new file mode 100644 index 00000000000..b696338ec9a --- /dev/null +++ b/exporters/otlp/internal/envconfig/envconfig.go @@ -0,0 +1,148 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package envconfig // import "go.opentelemetry.io/otel/exporters/otlp/internal/envconfig" + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net/url" + "strconv" + "strings" + "time" +) + +// ConfigFn is the generic function used to set a config +type ConfigFn func(*EnvOptionsReader) + +// EnvOptionsReader reads the required environment variables +type EnvOptionsReader struct { + GetEnv func(string) string + ReadFile func(string) ([]byte, error) + Namespace string +} + +// Apply runs every ConfigFn +func (e *EnvOptionsReader) Apply(opts ...ConfigFn) { + for _, o := range opts { + o(e) + } +} + +// GetEnvValue gets an OTLP environment variable value of the specified key +// using the GetEnv function. +// This function prepends the OTLP specified namespace to all key lookups. +func (e *EnvOptionsReader) GetEnvValue(key string) (string, bool) { + v := strings.TrimSpace(e.GetEnv(keyWithNamespace(e.Namespace, key))) + return v, v != "" +} + +// WithString retrieves the specified config and passes it to ConfigFn as a string +func WithString(n string, fn func(string)) func(e *EnvOptionsReader) { + return func(e *EnvOptionsReader) { + if v, ok := e.GetEnvValue(n); ok { + fn(v) + } + } +} + +// WithDuration retrieves the specified config and passes it to ConfigFn as a duration +func WithDuration(n string, fn func(time.Duration)) func(e *EnvOptionsReader) { + return func(e *EnvOptionsReader) { + if v, ok := e.GetEnvValue(n); ok { + if d, err := strconv.Atoi(v); err == nil { + fn(time.Duration(d) * time.Millisecond) + } + } + } +} + +// WithHeaders retrieves the specified config and passes it to ConfigFn as a map of HTTP headers +func WithHeaders(n string, fn func(map[string]string)) func(e *EnvOptionsReader) { + return func(e *EnvOptionsReader) { + if v, ok := e.GetEnvValue(n); ok { + fn(stringToHeader(v)) + } + } +} + +// WithURL retrieves the specified config and passes it to ConfigFn as a net/url.URL +func WithURL(n string, fn func(*url.URL)) func(e *EnvOptionsReader) { + return func(e *EnvOptionsReader) { + if v, ok := e.GetEnvValue(n); ok { + if u, err := url.Parse(v); err == nil { + fn(u) + } + } + } +} + +// WithTLSConfig retrieves the specified config and passes it to ConfigFn as a crypto/tls.Config +func WithTLSConfig(n string, fn func(*tls.Config)) func(e *EnvOptionsReader) { + return func(e *EnvOptionsReader) { + if v, ok := e.GetEnvValue(n); ok { + if b, err := e.ReadFile(v); err == nil { + if c, err := createTLSConfig(b); err == nil { + fn(c) + } + } + } + } +} + +func keyWithNamespace(ns, key string) string { + if ns == "" { + return key + } + return fmt.Sprintf("%s_%s", ns, key) +} + +func stringToHeader(value string) map[string]string { + headersPairs := strings.Split(value, ",") + headers := make(map[string]string) + + for _, header := range headersPairs { + nameValue := strings.SplitN(header, "=", 2) + if len(nameValue) < 2 { + continue + } + name, err := url.QueryUnescape(nameValue[0]) + if err != nil { + continue + } + trimmedName := strings.TrimSpace(name) + value, err := url.QueryUnescape(nameValue[1]) + if err != nil { + continue + } + trimmedValue := strings.TrimSpace(value) + + headers[trimmedName] = trimmedValue + } + + return headers +} + +func createTLSConfig(certBytes []byte) (*tls.Config, error) { + cp := x509.NewCertPool() + if ok := cp.AppendCertsFromPEM(certBytes); !ok { + return nil, errors.New("failed to append certificate to the cert pool") + } + + return &tls.Config{ + RootCAs: cp, + }, nil +} diff --git a/exporters/otlp/internal/envconfig/envconfig_test.go b/exporters/otlp/internal/envconfig/envconfig_test.go new file mode 100644 index 00000000000..b141cd42b93 --- /dev/null +++ b/exporters/otlp/internal/envconfig/envconfig_test.go @@ -0,0 +1,345 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package envconfig // import "go.opentelemetry.io/otel/exporters/otlp/internal/envconfig" + +import ( + "crypto/tls" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const WeakCertificate = ` +-----BEGIN CERTIFICATE----- +MIIBhzCCASygAwIBAgIRANHpHgAWeTnLZpTSxCKs0ggwCgYIKoZIzj0EAwIwEjEQ +MA4GA1UEChMHb3RlbC1nbzAeFw0yMTA0MDExMzU5MDNaFw0yMTA0MDExNDU5MDNa +MBIxEDAOBgNVBAoTB290ZWwtZ28wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAS9 +nWSkmPCxShxnp43F+PrOtbGV7sNfkbQ/kxzi9Ego0ZJdiXxkmv/C05QFddCW7Y0Z +sJCLHGogQsYnWJBXUZOVo2MwYTAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYI +KwYBBQUHAwEwDAYDVR0TAQH/BAIwADAsBgNVHREEJTAjgglsb2NhbGhvc3SHEAAA +AAAAAAAAAAAAAAAAAAGHBH8AAAEwCgYIKoZIzj0EAwIDSQAwRgIhANwZVVKvfvQ/ +1HXsTvgH+xTQswOwSSKYJ1cVHQhqK7ZbAiEAus8NxpTRnp5DiTMuyVmhVNPB+bVH +Lhnm4N/QDk5rek0= +-----END CERTIFICATE----- +` + +type testOption struct { + TestString string + TestDuration time.Duration + TestHeaders map[string]string + TestURL *url.URL + TestTLS *tls.Config +} + +func TestEnvConfig(t *testing.T) { + parsedURL, err := url.Parse("https://example.com") + assert.NoError(t, err) + + options := []testOption{} + for _, testcase := range []struct { + name string + reader EnvOptionsReader + configs []ConfigFn + expectedOptions []testOption + }{ + { + name: "with no namespace and a matching key", + reader: EnvOptionsReader{ + GetEnv: func(n string) string { + if n == "HELLO" { + return "world" + } + return "" + }, + }, + configs: []ConfigFn{ + WithString("HELLO", func(v string) { + options = append(options, testOption{TestString: v}) + }), + }, + expectedOptions: []testOption{ + { + TestString: "world", + }, + }, + }, + { + name: "with no namespace and a non-matching key", + reader: EnvOptionsReader{ + GetEnv: func(n string) string { + if n == "HELLO" { + return "world" + } + return "" + }, + }, + configs: []ConfigFn{ + WithString("HOLA", func(v string) { + options = append(options, testOption{TestString: v}) + }), + }, + expectedOptions: []testOption{}, + }, + { + name: "with a namespace and a matching key", + reader: EnvOptionsReader{ + Namespace: "MY_NAMESPACE", + GetEnv: func(n string) string { + if n == "MY_NAMESPACE_HELLO" { + return "world" + } + return "" + }, + }, + configs: []ConfigFn{ + WithString("HELLO", func(v string) { + options = append(options, testOption{TestString: v}) + }), + }, + expectedOptions: []testOption{ + { + TestString: "world", + }, + }, + }, + { + name: "with no namespace and a non-matching key", + reader: EnvOptionsReader{ + Namespace: "MY_NAMESPACE", + GetEnv: func(n string) string { + if n == "HELLO" { + return "world" + } + return "" + }, + }, + configs: []ConfigFn{ + WithString("HELLO", func(v string) { + options = append(options, testOption{TestString: v}) + }), + }, + expectedOptions: []testOption{}, + }, + { + name: "with a duration config", + reader: EnvOptionsReader{ + GetEnv: func(n string) string { + if n == "HELLO" { + return "60" + } + return "" + }, + }, + configs: []ConfigFn{ + WithDuration("HELLO", func(v time.Duration) { + options = append(options, testOption{TestDuration: v}) + }), + }, + expectedOptions: []testOption{ + { + TestDuration: 60_000_000, // 60 milliseconds + }, + }, + }, + { + name: "with an invalid duration config", + reader: EnvOptionsReader{ + GetEnv: func(n string) string { + if n == "HELLO" { + return "world" + } + return "" + }, + }, + configs: []ConfigFn{ + WithDuration("HELLO", func(v time.Duration) { + options = append(options, testOption{TestDuration: v}) + }), + }, + expectedOptions: []testOption{}, + }, + { + name: "with headers", + reader: EnvOptionsReader{ + GetEnv: func(n string) string { + if n == "HELLO" { + return "userId=42,userName=alice" + } + return "" + }, + }, + configs: []ConfigFn{ + WithHeaders("HELLO", func(v map[string]string) { + options = append(options, testOption{TestHeaders: v}) + }), + }, + expectedOptions: []testOption{ + { + TestHeaders: map[string]string{ + "userId": "42", + "userName": "alice", + }, + }, + }, + }, + { + name: "with invalid headers", + reader: EnvOptionsReader{ + GetEnv: func(n string) string { + if n == "HELLO" { + return "world" + } + return "" + }, + }, + configs: []ConfigFn{ + WithHeaders("HELLO", func(v map[string]string) { + options = append(options, testOption{TestHeaders: v}) + }), + }, + expectedOptions: []testOption{ + { + TestHeaders: map[string]string{}, + }, + }, + }, + { + name: "with URL", + reader: EnvOptionsReader{ + GetEnv: func(n string) string { + if n == "HELLO" { + return "https://example.com" + } + return "" + }, + }, + configs: []ConfigFn{ + WithURL("HELLO", func(v *url.URL) { + options = append(options, testOption{TestURL: v}) + }), + }, + expectedOptions: []testOption{ + { + TestURL: parsedURL, + }, + }, + }, + { + name: "with invalid URL", + reader: EnvOptionsReader{ + GetEnv: func(n string) string { + if n == "HELLO" { + return "i nvalid://url" + } + return "" + }, + }, + configs: []ConfigFn{ + WithURL("HELLO", func(v *url.URL) { + options = append(options, testOption{TestURL: v}) + }), + }, + expectedOptions: []testOption{}, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + testcase.reader.Apply(testcase.configs...) + assert.Equal(t, testcase.expectedOptions, options) + options = []testOption{} + }) + } +} + +func TestWithTLSConfig(t *testing.T) { + tlsCert, err := createTLSConfig([]byte(WeakCertificate)) + assert.NoError(t, err) + + reader := EnvOptionsReader{ + GetEnv: func(n string) string { + if n == "CERTIFICATE" { + return "/path/cert.pem" + } + return "" + }, + ReadFile: func(p string) ([]byte, error) { + if p == "/path/cert.pem" { + return []byte(WeakCertificate), nil + } + return []byte{}, nil + }, + } + + var option testOption + reader.Apply( + WithTLSConfig("CERTIFICATE", func(v *tls.Config) { + option = testOption{TestTLS: v} + })) + assert.Equal(t, tlsCert.RootCAs.Subjects(), option.TestTLS.RootCAs.Subjects()) +} + +func TestStringToHeader(t *testing.T) { + tests := []struct { + name string + value string + want map[string]string + }{ + { + name: "simple test", + value: "userId=alice", + want: map[string]string{"userId": "alice"}, + }, + { + name: "simple test with spaces", + value: " userId = alice ", + want: map[string]string{"userId": "alice"}, + }, + { + name: "multiples headers encoded", + value: "userId=alice,serverNode=DF%3A28,isProduction=false", + want: map[string]string{ + "userId": "alice", + "serverNode": "DF:28", + "isProduction": "false", + }, + }, + { + name: "invalid headers format", + value: "userId:alice", + want: map[string]string{}, + }, + { + name: "invalid key", + value: "%XX=missing,userId=alice", + want: map[string]string{ + "userId": "alice", + }, + }, + { + name: "invalid value", + value: "missing=%XX,userId=alice", + want: map[string]string{ + "userId": "alice", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, stringToHeader(tt.value)) + }) + } +} diff --git a/exporters/otlp/otlpmetric/internal/otlpconfig/envconfig.go b/exporters/otlp/otlpmetric/internal/otlpconfig/envconfig.go index ecf2ecaf9c2..d59912ddb6e 100644 --- a/exporters/otlp/otlpmetric/internal/otlpconfig/envconfig.go +++ b/exporters/otlp/otlpmetric/internal/otlpconfig/envconfig.go @@ -16,66 +16,58 @@ package otlpconfig // import "go.opentelemetry.io/otel/exporters/otlp/otlpmetric import ( "crypto/tls" - "fmt" "io/ioutil" "net/url" "os" "path" - "strconv" "strings" "time" - "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/internal/envconfig" ) -var DefaultEnvOptionsReader = EnvOptionsReader{ - GetEnv: os.Getenv, - ReadFile: ioutil.ReadFile, +// DefaultEnvOptionsReader is the default environments reader +var DefaultEnvOptionsReader = envconfig.EnvOptionsReader{ + GetEnv: os.Getenv, + ReadFile: ioutil.ReadFile, + Namespace: "OTEL_EXPORTER_OTLP", } +// ApplyGRPCEnvConfigs applies the env configurations for gRPC func ApplyGRPCEnvConfigs(cfg Config) Config { - return DefaultEnvOptionsReader.ApplyGRPCEnvConfigs(cfg) -} - -func ApplyHTTPEnvConfigs(cfg Config) Config { - return DefaultEnvOptionsReader.ApplyHTTPEnvConfigs(cfg) -} - -type EnvOptionsReader struct { - GetEnv func(string) string - ReadFile func(filename string) ([]byte, error) -} - -func (e *EnvOptionsReader) ApplyHTTPEnvConfigs(cfg Config) Config { - opts := e.GetOptionsFromEnv() + opts := getOptionsFromEnv() for _, opt := range opts { - cfg = opt.ApplyHTTPOption(cfg) + cfg = opt.ApplyGRPCOption(cfg) } return cfg } -func (e *EnvOptionsReader) ApplyGRPCEnvConfigs(cfg Config) Config { - opts := e.GetOptionsFromEnv() +// ApplyHTTPEnvConfigs applies the env configurations for HTTP +func ApplyHTTPEnvConfigs(cfg Config) Config { + opts := getOptionsFromEnv() for _, opt := range opts { - cfg = opt.ApplyGRPCOption(cfg) + cfg = opt.ApplyHTTPOption(cfg) } return cfg } -func (e *EnvOptionsReader) GetOptionsFromEnv() []GenericOption { - var opts []GenericOption +func getOptionsFromEnv() []GenericOption { + opts := []GenericOption{} - // Endpoint - if v, ok := e.getEnvValue("METRICS_ENDPOINT"); ok { - u, err := url.Parse(v) - // Ignore invalid values. - if err == nil { - // This is used to set the scheme for OTLP/HTTP. - if insecureSchema(u.Scheme) { - opts = append(opts, WithInsecure()) - } else { - opts = append(opts, WithSecure()) - } + DefaultEnvOptionsReader.Apply( + envconfig.WithURL("ENDPOINT", func(u *url.URL) { + opts = append(opts, withEndpointScheme(u)) + opts = append(opts, newSplitOption(func(cfg Config) Config { + cfg.Metrics.Endpoint = u.Host + // For OTLP/HTTP endpoint URLs without a per-signal + // configuration, the passed endpoint is used as a base URL + // and the signals are sent to these paths relative to that. + cfg.Metrics.URLPath = path.Join(u.Path, DefaultMetricsPath) + return cfg + }, withEndpointForGRPC(u))) + }), + envconfig.WithURL("METRICS_ENDPOINT", func(u *url.URL) { + opts = append(opts, withEndpointScheme(u)) opts = append(opts, newSplitOption(func(cfg Config) Config { cfg.Metrics.Endpoint = u.Host // For endpoint URLs for OTLP/HTTP per-signal variables, the @@ -88,141 +80,50 @@ func (e *EnvOptionsReader) GetOptionsFromEnv() []GenericOption { } cfg.Metrics.URLPath = path return cfg - }, func(cfg Config) Config { - // For OTLP/gRPC endpoints, this is the target to which the - // exporter is going to send telemetry. - cfg.Metrics.Endpoint = path.Join(u.Host, u.Path) - return cfg - })) - } - } else if v, ok = e.getEnvValue("ENDPOINT"); ok { - u, err := url.Parse(v) - // Ignore invalid values. - if err == nil { - // This is used to set the scheme for OTLP/HTTP. - if insecureSchema(u.Scheme) { - opts = append(opts, WithInsecure()) - } else { - opts = append(opts, WithSecure()) - } - opts = append(opts, newSplitOption(func(cfg Config) Config { - cfg.Metrics.Endpoint = u.Host - // For OTLP/HTTP endpoint URLs without a per-signal - // configuration, the passed endpoint is used as a base URL - // and the signals are sent to these paths relative to that. - cfg.Metrics.URLPath = path.Join(u.Path, DefaultMetricsPath) - return cfg - }, func(cfg Config) Config { - // For OTLP/gRPC endpoints, this is the target to which the - // exporter is going to send telemetry. - cfg.Metrics.Endpoint = path.Join(u.Host, u.Path) - return cfg - })) - } - } - - // Certificate File - if path, ok := e.getEnvValue("CERTIFICATE"); ok { - if tls, err := e.readTLSConfig(path); err == nil { - opts = append(opts, WithTLSClientConfig(tls)) - } else { - otel.Handle(fmt.Errorf("failed to configure otlp exporter certificate '%s': %w", path, err)) - } - } - if path, ok := e.getEnvValue("METRICS_CERTIFICATE"); ok { - if tls, err := e.readTLSConfig(path); err == nil { - opts = append(opts, WithTLSClientConfig(tls)) - } else { - otel.Handle(fmt.Errorf("failed to configure otlp exporter certificate '%s': %w", path, err)) - } - } - - // Headers - if h, ok := e.getEnvValue("HEADERS"); ok { - opts = append(opts, WithHeaders(stringToHeader(h))) - } - if h, ok := e.getEnvValue("METRICS_HEADERS"); ok { - opts = append(opts, WithHeaders(stringToHeader(h))) - } - - // Compression - if c, ok := e.getEnvValue("COMPRESSION"); ok { - opts = append(opts, WithCompression(stringToCompression(c))) - } - if c, ok := e.getEnvValue("METRICS_COMPRESSION"); ok { - opts = append(opts, WithCompression(stringToCompression(c))) - } - - // Timeout - if t, ok := e.getEnvValue("TIMEOUT"); ok { - if d, err := strconv.Atoi(t); err == nil { - opts = append(opts, WithTimeout(time.Duration(d)*time.Millisecond)) - } - } - if t, ok := e.getEnvValue("METRICS_TIMEOUT"); ok { - if d, err := strconv.Atoi(t); err == nil { - opts = append(opts, WithTimeout(time.Duration(d)*time.Millisecond)) - } - } + }, withEndpointForGRPC(u))) + }), + envconfig.WithTLSConfig("CERTIFICATE", func(c *tls.Config) { opts = append(opts, WithTLSClientConfig(c)) }), + envconfig.WithTLSConfig("METRICS_CERTIFICATE", func(c *tls.Config) { opts = append(opts, WithTLSClientConfig(c)) }), + envconfig.WithHeaders("HEADERS", func(h map[string]string) { opts = append(opts, WithHeaders(h)) }), + envconfig.WithHeaders("METRICS_HEADERS", func(h map[string]string) { opts = append(opts, WithHeaders(h)) }), + WithEnvCompression("COMPRESSION", func(c Compression) { opts = append(opts, WithCompression(c)) }), + WithEnvCompression("METRICS_COMPRESSION", func(c Compression) { opts = append(opts, WithCompression(c)) }), + envconfig.WithDuration("TIMEOUT", func(d time.Duration) { opts = append(opts, WithTimeout(d)) }), + envconfig.WithDuration("METRICS_TIMEOUT", func(d time.Duration) { opts = append(opts, WithTimeout(d)) }), + ) return opts } -func insecureSchema(schema string) bool { - switch strings.ToLower(schema) { - case "http", "unix": - return true - default: - return false +func withEndpointForGRPC(u *url.URL) func(cfg Config) Config { + return func(cfg Config) Config { + // For OTLP/gRPC endpoints, this is the target to which the + // exporter is going to send telemetry. + cfg.Metrics.Endpoint = path.Join(u.Host, u.Path) + return cfg } } -// getEnvValue gets an OTLP environment variable value of the specified key using the GetEnv function. -// This function already prepends the OTLP prefix to all key lookup. -func (e *EnvOptionsReader) getEnvValue(key string) (string, bool) { - v := strings.TrimSpace(e.GetEnv(fmt.Sprintf("OTEL_EXPORTER_OTLP_%s", key))) - return v, v != "" -} - -func (e *EnvOptionsReader) readTLSConfig(path string) (*tls.Config, error) { - b, err := e.ReadFile(path) - if err != nil { - return nil, err - } - return CreateTLSConfig(b) -} +// WithEnvCompression retrieves the specified config and passes it to ConfigFn as a Compression +func WithEnvCompression(n string, fn func(Compression)) func(e *envconfig.EnvOptionsReader) { + return func(e *envconfig.EnvOptionsReader) { + if v, ok := e.GetEnvValue(n); ok { + cp := NoCompression + switch v { + case "gzip": + cp = GzipCompression + } -func stringToCompression(value string) Compression { - switch value { - case "gzip": - return GzipCompression + fn(cp) + } } - - return NoCompression } -func stringToHeader(value string) map[string]string { - headersPairs := strings.Split(value, ",") - headers := make(map[string]string) - - for _, header := range headersPairs { - nameValue := strings.SplitN(header, "=", 2) - if len(nameValue) < 2 { - continue - } - name, err := url.QueryUnescape(nameValue[0]) - if err != nil { - continue - } - trimmedName := strings.TrimSpace(name) - value, err := url.QueryUnescape(nameValue[1]) - if err != nil { - continue - } - trimmedValue := strings.TrimSpace(value) - - headers[trimmedName] = trimmedValue +func withEndpointScheme(u *url.URL) GenericOption { + switch strings.ToLower(u.Scheme) { + case "http", "unix": + return WithInsecure() + default: + return WithSecure() } - - return headers } diff --git a/exporters/otlp/otlpmetric/internal/otlpconfig/envconfig_test.go b/exporters/otlp/otlpmetric/internal/otlpconfig/envconfig_test.go index 7a6316a2d10..25021f7328c 100644 --- a/exporters/otlp/otlpmetric/internal/otlpconfig/envconfig_test.go +++ b/exporters/otlp/otlpmetric/internal/otlpconfig/envconfig_test.go @@ -13,63 +13,3 @@ // limitations under the License. package otlpconfig - -import ( - "reflect" - "testing" -) - -func TestStringToHeader(t *testing.T) { - tests := []struct { - name string - value string - want map[string]string - }{ - { - name: "simple test", - value: "userId=alice", - want: map[string]string{"userId": "alice"}, - }, - { - name: "simple test with spaces", - value: " userId = alice ", - want: map[string]string{"userId": "alice"}, - }, - { - name: "multiples headers encoded", - value: "userId=alice,serverNode=DF%3A28,isProduction=false", - want: map[string]string{ - "userId": "alice", - "serverNode": "DF:28", - "isProduction": "false", - }, - }, - { - name: "invalid headers format", - value: "userId:alice", - want: map[string]string{}, - }, - { - name: "invalid key", - value: "%XX=missing,userId=alice", - want: map[string]string{ - "userId": "alice", - }, - }, - { - name: "invalid value", - value: "missing=%XX,userId=alice", - want: map[string]string{ - "userId": "alice", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := stringToHeader(tt.value); !reflect.DeepEqual(got, tt.want) { - t.Errorf("stringToHeader() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/exporters/otlp/otlpmetric/internal/otlpconfig/options_test.go b/exporters/otlp/otlpmetric/internal/otlpconfig/options_test.go index 44c9af4d94c..fad37d60be5 100644 --- a/exporters/otlp/otlpmetric/internal/otlpconfig/options_test.go +++ b/exporters/otlp/otlpmetric/internal/otlpconfig/options_test.go @@ -21,6 +21,7 @@ import ( "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/exporters/otlp/internal/envconfig" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/internal/otlpconfig" ) @@ -383,9 +384,10 @@ func TestConfigs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { origEOR := otlpconfig.DefaultEnvOptionsReader - otlpconfig.DefaultEnvOptionsReader = otlpconfig.EnvOptionsReader{ - GetEnv: tt.env.getEnv, - ReadFile: tt.fileReader.readFile, + otlpconfig.DefaultEnvOptionsReader = envconfig.EnvOptionsReader{ + GetEnv: tt.env.getEnv, + ReadFile: tt.fileReader.readFile, + Namespace: "OTEL_EXPORTER_OTLP", } t.Cleanup(func() { otlpconfig.DefaultEnvOptionsReader = origEOR }) diff --git a/exporters/otlp/otlptrace/internal/otlpconfig/envconfig.go b/exporters/otlp/otlptrace/internal/otlpconfig/envconfig.go index 77f13a1937b..1ff8b1d5fc9 100644 --- a/exporters/otlp/otlptrace/internal/otlpconfig/envconfig.go +++ b/exporters/otlp/otlptrace/internal/otlpconfig/envconfig.go @@ -16,66 +16,58 @@ package otlpconfig // import "go.opentelemetry.io/otel/exporters/otlp/otlptrace/ import ( "crypto/tls" - "fmt" "io/ioutil" "net/url" "os" "path" - "strconv" "strings" "time" - "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/internal/envconfig" ) -var DefaultEnvOptionsReader = EnvOptionsReader{ - GetEnv: os.Getenv, - ReadFile: ioutil.ReadFile, +// DefaultEnvOptionsReader is the default environments reader +var DefaultEnvOptionsReader = envconfig.EnvOptionsReader{ + GetEnv: os.Getenv, + ReadFile: ioutil.ReadFile, + Namespace: "OTEL_EXPORTER_OTLP", } +// ApplyGRPCEnvConfigs applies the env configurations for gRPC func ApplyGRPCEnvConfigs(cfg Config) Config { - return DefaultEnvOptionsReader.ApplyGRPCEnvConfigs(cfg) -} - -func ApplyHTTPEnvConfigs(cfg Config) Config { - return DefaultEnvOptionsReader.ApplyHTTPEnvConfigs(cfg) -} - -type EnvOptionsReader struct { - GetEnv func(string) string - ReadFile func(filename string) ([]byte, error) -} - -func (e *EnvOptionsReader) ApplyHTTPEnvConfigs(cfg Config) Config { - opts := e.GetOptionsFromEnv() + opts := getOptionsFromEnv() for _, opt := range opts { - cfg = opt.ApplyHTTPOption(cfg) + cfg = opt.ApplyGRPCOption(cfg) } return cfg } -func (e *EnvOptionsReader) ApplyGRPCEnvConfigs(cfg Config) Config { - opts := e.GetOptionsFromEnv() +// ApplyHTTPEnvConfigs applies the env configurations for HTTP +func ApplyHTTPEnvConfigs(cfg Config) Config { + opts := getOptionsFromEnv() for _, opt := range opts { - cfg = opt.ApplyGRPCOption(cfg) + cfg = opt.ApplyHTTPOption(cfg) } return cfg } -func (e *EnvOptionsReader) GetOptionsFromEnv() []GenericOption { - var opts []GenericOption +func getOptionsFromEnv() []GenericOption { + opts := []GenericOption{} - // Endpoint - if v, ok := e.getEnvValue("TRACES_ENDPOINT"); ok { - u, err := url.Parse(v) - // Ignore invalid values. - if err == nil { - // This is used to set the scheme for OTLP/HTTP. - if insecureSchema(u.Scheme) { - opts = append(opts, WithInsecure()) - } else { - opts = append(opts, WithSecure()) - } + DefaultEnvOptionsReader.Apply( + envconfig.WithURL("ENDPOINT", func(u *url.URL) { + opts = append(opts, withEndpointScheme(u)) + opts = append(opts, newSplitOption(func(cfg Config) Config { + cfg.Traces.Endpoint = u.Host + // For OTLP/HTTP endpoint URLs without a per-signal + // configuration, the passed endpoint is used as a base URL + // and the signals are sent to these paths relative to that. + cfg.Traces.URLPath = path.Join(u.Path, DefaultTracesPath) + return cfg + }, withEndpointForGRPC(u))) + }), + envconfig.WithURL("TRACES_ENDPOINT", func(u *url.URL) { + opts = append(opts, withEndpointScheme(u)) opts = append(opts, newSplitOption(func(cfg Config) Config { cfg.Traces.Endpoint = u.Host // For endpoint URLs for OTLP/HTTP per-signal variables, the @@ -88,140 +80,50 @@ func (e *EnvOptionsReader) GetOptionsFromEnv() []GenericOption { } cfg.Traces.URLPath = path return cfg - }, func(cfg Config) Config { - // For OTLP/gRPC endpoints, this is the target to which the - // exporter is going to send telemetry. - cfg.Traces.Endpoint = path.Join(u.Host, u.Path) - return cfg - })) - } - } else if v, ok = e.getEnvValue("ENDPOINT"); ok { - u, err := url.Parse(v) - // Ignore invalid values. - if err == nil { - // This is used to set the scheme for OTLP/HTTP. - if insecureSchema(u.Scheme) { - opts = append(opts, WithInsecure()) - } else { - opts = append(opts, WithSecure()) - } - opts = append(opts, newSplitOption(func(cfg Config) Config { - cfg.Traces.Endpoint = u.Host - // For OTLP/HTTP endpoint URLs without a per-signal - // configuration, the passed endpoint is used as a base URL - // and the signals are sent to these paths relative to that. - cfg.Traces.URLPath = path.Join(u.Path, DefaultTracesPath) - return cfg - }, func(cfg Config) Config { - // For OTLP/gRPC endpoints, this is the target to which the - // exporter is going to send telemetry. - cfg.Traces.Endpoint = path.Join(u.Host, u.Path) - return cfg - })) - } - } - - // Certificate File - if path, ok := e.getEnvValue("CERTIFICATE"); ok { - if tls, err := e.readTLSConfig(path); err == nil { - opts = append(opts, WithTLSClientConfig(tls)) - } else { - otel.Handle(fmt.Errorf("failed to configure otlp exporter certificate '%s': %w", path, err)) - } - } - if path, ok := e.getEnvValue("TRACES_CERTIFICATE"); ok { - if tls, err := e.readTLSConfig(path); err == nil { - opts = append(opts, WithTLSClientConfig(tls)) - } else { - otel.Handle(fmt.Errorf("failed to configure otlp traces exporter certificate '%s': %w", path, err)) - } - } - - // Headers - if h, ok := e.getEnvValue("HEADERS"); ok { - opts = append(opts, WithHeaders(stringToHeader(h))) - } - if h, ok := e.getEnvValue("TRACES_HEADERS"); ok { - opts = append(opts, WithHeaders(stringToHeader(h))) - } - - // Compression - if c, ok := e.getEnvValue("COMPRESSION"); ok { - opts = append(opts, WithCompression(stringToCompression(c))) - } - if c, ok := e.getEnvValue("TRACES_COMPRESSION"); ok { - opts = append(opts, WithCompression(stringToCompression(c))) - } - // Timeout - if t, ok := e.getEnvValue("TIMEOUT"); ok { - if d, err := strconv.Atoi(t); err == nil { - opts = append(opts, WithTimeout(time.Duration(d)*time.Millisecond)) - } - } - if t, ok := e.getEnvValue("TRACES_TIMEOUT"); ok { - if d, err := strconv.Atoi(t); err == nil { - opts = append(opts, WithTimeout(time.Duration(d)*time.Millisecond)) - } - } + }, withEndpointForGRPC(u))) + }), + envconfig.WithTLSConfig("CERTIFICATE", func(c *tls.Config) { opts = append(opts, WithTLSClientConfig(c)) }), + envconfig.WithTLSConfig("TRACES_CERTIFICATE", func(c *tls.Config) { opts = append(opts, WithTLSClientConfig(c)) }), + envconfig.WithHeaders("HEADERS", func(h map[string]string) { opts = append(opts, WithHeaders(h)) }), + envconfig.WithHeaders("TRACES_HEADERS", func(h map[string]string) { opts = append(opts, WithHeaders(h)) }), + WithEnvCompression("COMPRESSION", func(c Compression) { opts = append(opts, WithCompression(c)) }), + WithEnvCompression("TRACES_COMPRESSION", func(c Compression) { opts = append(opts, WithCompression(c)) }), + envconfig.WithDuration("TIMEOUT", func(d time.Duration) { opts = append(opts, WithTimeout(d)) }), + envconfig.WithDuration("TRACES_TIMEOUT", func(d time.Duration) { opts = append(opts, WithTimeout(d)) }), + ) return opts } -func insecureSchema(schema string) bool { - switch strings.ToLower(schema) { +func withEndpointScheme(u *url.URL) GenericOption { + switch strings.ToLower(u.Scheme) { case "http", "unix": - return true + return WithInsecure() default: - return false - } -} - -// getEnvValue gets an OTLP environment variable value of the specified key using the GetEnv function. -// This function already prepends the OTLP prefix to all key lookup. -func (e *EnvOptionsReader) getEnvValue(key string) (string, bool) { - v := strings.TrimSpace(e.GetEnv(fmt.Sprintf("OTEL_EXPORTER_OTLP_%s", key))) - return v, v != "" -} - -func (e *EnvOptionsReader) readTLSConfig(path string) (*tls.Config, error) { - b, err := e.ReadFile(path) - if err != nil { - return nil, err + return WithSecure() } - return CreateTLSConfig(b) } -func stringToCompression(value string) Compression { - switch value { - case "gzip": - return GzipCompression +func withEndpointForGRPC(u *url.URL) func(cfg Config) Config { + return func(cfg Config) Config { + // For OTLP/gRPC endpoints, this is the target to which the + // exporter is going to send telemetry. + cfg.Traces.Endpoint = path.Join(u.Host, u.Path) + return cfg } - - return NoCompression } -func stringToHeader(value string) map[string]string { - headersPairs := strings.Split(value, ",") - headers := make(map[string]string) +// WithEnvCompression retrieves the specified config and passes it to ConfigFn as a Compression +func WithEnvCompression(n string, fn func(Compression)) func(e *envconfig.EnvOptionsReader) { + return func(e *envconfig.EnvOptionsReader) { + if v, ok := e.GetEnvValue(n); ok { + cp := NoCompression + switch v { + case "gzip": + cp = GzipCompression + } - for _, header := range headersPairs { - nameValue := strings.SplitN(header, "=", 2) - if len(nameValue) < 2 { - continue - } - name, err := url.QueryUnescape(nameValue[0]) - if err != nil { - continue + fn(cp) } - trimmedName := strings.TrimSpace(name) - value, err := url.QueryUnescape(nameValue[1]) - if err != nil { - continue - } - trimmedValue := strings.TrimSpace(value) - - headers[trimmedName] = trimmedValue } - - return headers } diff --git a/exporters/otlp/otlptrace/internal/otlpconfig/envconfig_test.go b/exporters/otlp/otlptrace/internal/otlpconfig/envconfig_test.go index 7a6316a2d10..25021f7328c 100644 --- a/exporters/otlp/otlptrace/internal/otlpconfig/envconfig_test.go +++ b/exporters/otlp/otlptrace/internal/otlpconfig/envconfig_test.go @@ -13,63 +13,3 @@ // limitations under the License. package otlpconfig - -import ( - "reflect" - "testing" -) - -func TestStringToHeader(t *testing.T) { - tests := []struct { - name string - value string - want map[string]string - }{ - { - name: "simple test", - value: "userId=alice", - want: map[string]string{"userId": "alice"}, - }, - { - name: "simple test with spaces", - value: " userId = alice ", - want: map[string]string{"userId": "alice"}, - }, - { - name: "multiples headers encoded", - value: "userId=alice,serverNode=DF%3A28,isProduction=false", - want: map[string]string{ - "userId": "alice", - "serverNode": "DF:28", - "isProduction": "false", - }, - }, - { - name: "invalid headers format", - value: "userId:alice", - want: map[string]string{}, - }, - { - name: "invalid key", - value: "%XX=missing,userId=alice", - want: map[string]string{ - "userId": "alice", - }, - }, - { - name: "invalid value", - value: "missing=%XX,userId=alice", - want: map[string]string{ - "userId": "alice", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := stringToHeader(tt.value); !reflect.DeepEqual(got, tt.want) { - t.Errorf("stringToHeader() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/exporters/otlp/otlptrace/internal/otlpconfig/options_test.go b/exporters/otlp/otlptrace/internal/otlpconfig/options_test.go index 4efa2f7c630..30dc5ea8015 100644 --- a/exporters/otlp/otlptrace/internal/otlpconfig/options_test.go +++ b/exporters/otlp/otlptrace/internal/otlpconfig/options_test.go @@ -21,6 +21,7 @@ import ( "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/exporters/otlp/internal/envconfig" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/internal/otlpconfig" ) @@ -381,9 +382,10 @@ func TestConfigs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { origEOR := otlpconfig.DefaultEnvOptionsReader - otlpconfig.DefaultEnvOptionsReader = otlpconfig.EnvOptionsReader{ - GetEnv: tt.env.getEnv, - ReadFile: tt.fileReader.readFile, + otlpconfig.DefaultEnvOptionsReader = envconfig.EnvOptionsReader{ + GetEnv: tt.env.getEnv, + ReadFile: tt.fileReader.readFile, + Namespace: "OTEL_EXPORTER_OTLP", } t.Cleanup(func() { otlpconfig.DefaultEnvOptionsReader = origEOR })