diff --git a/otelx/config.schema.json b/otelx/config.schema.json index 1a668f31..73440877 100644 --- a/otelx/config.schema.json +++ b/otelx/config.schema.json @@ -105,7 +105,7 @@ "properties": { "server_url": { "type": "string", - "description": "The endpoint of the OTLP exporter (HTTP) where spans should be sent to.", + "description": "The endpoint of the OTLP exporter (HTTP) where spans should be sent to. Accepts host:port or an http(s) URL. A URL path is treated as a base path and /v1/traces is appended unless already present.", "anyOf": [ { "title": "IPv6 Address and Port", @@ -118,9 +118,14 @@ { "title": "Hostname and Port", "pattern": "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9]):([0-9]*)$" + }, + { + "title": "URL", + "pattern": "^https?://.+$", + "examples": ["https://otlp-gateway.example.com/otlp"] } ], - "examples": ["localhost:4318"] + "examples": ["localhost:4318", "https://otlp-gateway.example.com/otlp"] }, "insecure": { "type": "boolean", diff --git a/otelx/config_test.go b/otelx/config_test.go new file mode 100644 index 00000000..e470599e --- /dev/null +++ b/otelx/config_test.go @@ -0,0 +1,48 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package otelx + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/jsonschema/v3" +) + +func TestOTLPServerURLSchema(t *testing.T) { + c := jsonschema.NewCompiler() + require.NoError(t, AddConfigSchema(c)) + + schema, err := c.Compile(context.Background(), ConfigSchemaID) + require.NoError(t, err) + + for _, tc := range []struct { + name string + url string + wantErr bool + }{ + {name: "host and port", url: "localhost:4318"}, + {name: "ipv4 and port", url: "127.0.0.1:4318"}, + {name: "grafana cloud url", url: "https://otlp-gateway-prod-gb-south-0.grafana.net/otlp"}, + {name: "http url with path", url: "http://collector.example.com:4318/otlp"}, + {name: "https url without path", url: "https://collector.example.com:4318"}, + {name: "bare hostname", url: "localhost", wantErr: true}, + {name: "path without scheme", url: "collector.example.com/otlp", wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + doc := fmt.Sprintf(`{"provider":"otel","providers":{"otlp":{"server_url":%q}}}`, tc.url) + err := schema.Validate(bytes.NewBufferString(doc)) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} diff --git a/otelx/otlp.go b/otelx/otlp.go index f5c3d7d0..60601af7 100644 --- a/otelx/otlp.go +++ b/otelx/otlp.go @@ -5,6 +5,8 @@ package otelx import ( "context" + "net/url" + "strings" "go.opentelemetry.io/contrib/propagators/b3" jaegerPropagator "go.opentelemetry.io/contrib/propagators/jaeger" @@ -18,22 +20,53 @@ import ( "go.opentelemetry.io/otel/trace" ) -func SetupOTLP(t *Tracer, tracerName string, c *Config) (trace.Tracer, error) { - ctx := context.Background() +const otlpTracesPath = "/v1/traces" - clientOpts := []otlptracehttp.Option{ - otlptracehttp.WithEndpoint(c.Providers.OTLP.ServerURL), +func otlpHTTPOptions(c *OTLPConfig) []otlptracehttp.Option { + opts := make([]otlptracehttp.Option, 0, 4) + insecure := c.Insecure + + if u, err := url.Parse(c.ServerURL); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" { + opts = append(opts, otlptracehttp.WithEndpoint(u.Host)) + if p := otlpTracesURLPath(u.Path); p != "" { + opts = append(opts, otlptracehttp.WithURLPath(p)) + } + insecure = u.Scheme == "http" + } else { + opts = append(opts, otlptracehttp.WithEndpoint(c.ServerURL)) } - if c.Providers.OTLP.Insecure { - clientOpts = append(clientOpts, otlptracehttp.WithInsecure()) + if insecure { + opts = append(opts, otlptracehttp.WithInsecure()) } - if c.Providers.OTLP.AuthorizationHeader != "" { - clientOpts = append(clientOpts, - otlptracehttp.WithHeaders(map[string]string{"Authorization": c.Providers.OTLP.AuthorizationHeader}), - ) + if c.AuthorizationHeader != "" { + opts = append(opts, otlptracehttp.WithHeaders(map[string]string{"Authorization": c.AuthorizationHeader})) + } + + return opts +} + +// otlpTracesURLPath maps a URL path to the OTLP HTTP traces path. +// Empty or "/" leaves the exporter default (/v1/traces). Other paths are treated +// as a base prefix (OTEL_EXPORTER_OTLP_ENDPOINT semantics) unless they already +// end with /v1/traces. +func otlpTracesURLPath(p string) string { + p = strings.TrimSpace(p) + if p == "" || p == "/" { + return "" } + p = strings.TrimRight(p, "/") + if strings.HasSuffix(p, otlpTracesPath) { + return p + } + return p + otlpTracesPath +} + +func SetupOTLP(t *Tracer, tracerName string, c *Config) (trace.Tracer, error) { + ctx := context.Background() + + clientOpts := otlpHTTPOptions(&c.Providers.OTLP) exp, err := otlptrace.New( ctx, otlptracehttp.NewClient(clientOpts...), diff --git a/otelx/otlp_test.go b/otelx/otlp_test.go new file mode 100644 index 00000000..b1775ed0 --- /dev/null +++ b/otelx/otlp_test.go @@ -0,0 +1,109 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package otelx + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +func TestSetupOTLPRequestPath(t *testing.T) { + for _, tc := range []struct { + name string + serverURL func(base, host string) string + wantPath string + }{ + { + name: "host and port", + serverURL: func(_, host string) string { + return host + }, + wantPath: "/v1/traces", + }, + { + name: "grafana cloud style url", + serverURL: func(base, _ string) string { + return base + "/otlp" + }, + wantPath: "/otlp/v1/traces", + }, + { + name: "full traces url", + serverURL: func(base, _ string) string { + return base + "/v1/traces" + }, + wantPath: "/v1/traces", + }, + } { + t.Run(tc.name, func(t *testing.T) { + prevTP := otel.GetTracerProvider() + prevProp := otel.GetTextMapPropagator() + t.Cleanup(func() { + otel.SetTracerProvider(prevTP) + otel.SetTextMapPropagator(prevProp) + }) + + got := make(chan string, 1) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case got <- r.URL.Path: + default: + } + })) + t.Cleanup(ts.Close) + + tsu, err := url.Parse(ts.URL) + require.NoError(t, err) + + _, err = SetupOTLP(nil, "test", &Config{ + ServiceName: "ORY X", + Provider: "otel", + Providers: ProvidersConfig{ + OTLP: OTLPConfig{ + ServerURL: tc.serverURL(ts.URL, tsu.Host), + Insecure: true, + Sampling: OTLPSampling{ + SamplingRatio: 1, + }, + }, + }, + }) + require.NoError(t, err) + + tp, ok := otel.GetTracerProvider().(*sdktrace.TracerProvider) + require.True(t, ok) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = tp.Shutdown(ctx) + }) + + tracer := tp.Tracer("test") + _, span := tracer.Start(context.Background(), "testSpan") + span.SetAttributes(attribute.Bool("testAttribute", true)) + span.End() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, tp.ForceFlush(ctx)) + + select { + case path := <-got: + assert.Equal(t, tc.wantPath, path) + case <-ctx.Done(): + t.Fatalf("collector did not receive a span; want path %s", tc.wantPath) + } + }) + } +}