diff --git a/lib/observability/tracing/client.go b/lib/observability/tracing/client.go new file mode 100644 index 0000000000000..f43803056c273 --- /dev/null +++ b/lib/observability/tracing/client.go @@ -0,0 +1,224 @@ +// Copyright 2022 Gravitational, Inc +// +// 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 tracing + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + "sync" + "time" + + "github.com/gravitational/trace" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + otlp "go.opentelemetry.io/proto/otlp/trace/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/protobuf/encoding/protojson" +) + +type noopClient struct{} + +func (noopClient) Start(context.Context) error { return nil } +func (noopClient) Stop(context.Context) error { return nil } +func (noopClient) UploadTraces(context.Context, []*otlp.ResourceSpans) error { return nil } + +// NewNoopClient returns an oltptrace.Client that does nothing +func NewNoopClient() otlptrace.Client { + return noopClient{} +} + +// NewStartedClient either returns the provided Config.Client or constructs +// a new client that is connected to the Config.ExporterURL with the +// appropriate TLS credentials. The client is started prior to returning to +// the caller. +func NewStartedClient(ctx context.Context, cfg Config) (otlptrace.Client, error) { + if err := cfg.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + clt, err := NewClient(cfg) + if err != nil { + return nil, trace.Wrap(err) + } + + ctx, cancel := context.WithTimeout(ctx, cfg.DialTimeout) + defer cancel() + if err := clt.Start(ctx); err != nil { + return nil, trace.Wrap(err) + } + + return clt, nil +} + +// NewClient either returns the provided Config.Client or constructs +// a new client that is connected to the Config.ExporterURL with the +// appropriate TLS credentials. The returned client is not started, +// it will be started by the provider if passed to one. +func NewClient(cfg Config) (otlptrace.Client, error) { + if err := cfg.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + if cfg.Client != nil { + return cfg.Client, nil + } + + var httpOptions []otlptracehttp.Option + grpcOptions := []otlptracegrpc.Option{otlptracegrpc.WithDialOption(grpc.WithBlock())} + + if cfg.TLSConfig != nil { + httpOptions = append(httpOptions, otlptracehttp.WithTLSClientConfig(cfg.TLSConfig.Clone())) + grpcOptions = append(grpcOptions, otlptracegrpc.WithTLSCredentials(credentials.NewTLS(cfg.TLSConfig.Clone()))) + } else { + httpOptions = append(httpOptions, otlptracehttp.WithInsecure()) + grpcOptions = append(grpcOptions, otlptracegrpc.WithInsecure()) + } + + var traceClient otlptrace.Client + switch cfg.exporterURL.Scheme { + case "http", "https": + httpOptions = append(httpOptions, otlptracehttp.WithEndpoint(cfg.Endpoint())) + traceClient = otlptracehttp.NewClient(httpOptions...) + case "grpc": + grpcOptions = append(grpcOptions, otlptracegrpc.WithEndpoint(cfg.Endpoint())) + traceClient = otlptracegrpc.NewClient(grpcOptions...) + case "file": + limit := DefaultFileLimit + rawLimit := cfg.exporterURL.Query().Get("limit") + if rawLimit != "" { + convertedLimit, err := strconv.ParseUint(rawLimit, 10, 0) + if err != nil { + return nil, trace.Wrap(err) + } + limit = convertedLimit + } + + client, err := NewRotatingFileClient(cfg.Endpoint(), limit) + if err != nil { + return nil, trace.Wrap(err) + } + traceClient = client + default: + return nil, trace.BadParameter("unsupported exporter scheme: %q", cfg.exporterURL.Scheme) + } + + return traceClient, nil +} + +var _ otlptrace.Client = (*RotatingFileClient)(nil) + +// RotatingFileClient is an otlptrace.Client that writes traces to a file. It +// will automatically rotate files when they reach the configured limit. Each +// line in the file is a JSON-encoded otlp.Span. +type RotatingFileClient struct { + dir string + limit uint64 + written uint64 + + lock sync.Mutex + file *os.File +} + +func fileName() string { + return fmt.Sprintf("%d-*.trace", time.Now().UTC().UnixNano()) +} + +// DefaultFileLimit is the default file size limit used before +// rotating to a new traces file +const DefaultFileLimit uint64 = 1048576 * 100 // 100MB + +// NewRotatingFileClient returns a new RotatingFileClient that will store files in the +// provided directory. The files will be rotated when they reach the provided limit. +func NewRotatingFileClient(dir string, limit uint64) (*RotatingFileClient, error) { + if err := os.MkdirAll(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) { + return nil, trace.ConvertSystemError(err) + } + + f, err := os.CreateTemp(dir, fileName()) + if err != nil { + return nil, trace.ConvertSystemError(err) + } + + return &RotatingFileClient{ + dir: dir, + limit: limit, + file: f, + }, nil +} + +// Start is a noop needed to satisfy the otlptrace.Client interface. +func (f *RotatingFileClient) Start(ctx context.Context) error { + return nil +} + +// Stop closes the active file and sets it to nil to indicate to UploadTraces +// that no more traces should be written. +func (f *RotatingFileClient) Stop(ctx context.Context) error { + f.lock.Lock() + defer f.lock.Unlock() + + err := f.file.Close() + f.file = nil + return trace.Wrap(err) +} + +var ErrShutdown = errors.New("the client is shutdown") + +// UploadTraces writes the provided spans to a file in the configured directory. If writing another span +// to the file would cause it to exceed the limit, then the file is first rotated before the write is +// attempted. In the event that Stop has already been called this will always return ErrShutdown. +func (f *RotatingFileClient) UploadTraces(ctx context.Context, protoSpans []*otlp.ResourceSpans) error { + f.lock.Lock() + defer f.lock.Unlock() + + if f.file == nil { + return ErrShutdown + } + + for _, span := range protoSpans { + msg, err := protojson.Marshal(span) + if err != nil { + return trace.Wrap(err) + } + + // Open a new file if this write would exceed the configured limit + // *IF* we have already written data. Otherwise, we'll allow this + // write to exceed the limit to prevent any empty files from existing. + if uint64(len(msg))+f.written >= f.limit && f.written != 0 { + newFile, err := os.CreateTemp(f.dir, fileName()) + if err != nil { + return trace.ConvertSystemError(err) + } + + var oldFile *os.File + oldFile, f.file, f.written = f.file, newFile, 0 + _ = oldFile.Close() + } + + msg = append(msg, '\n') + n, err := f.file.Write(msg) + f.written += uint64(n) + if err != nil { + return trace.ConvertSystemError(err) + } + } + + return nil +} diff --git a/lib/observability/tracing/client_test.go b/lib/observability/tracing/client_test.go new file mode 100644 index 0000000000000..a08ef14dfa40a --- /dev/null +++ b/lib/observability/tracing/client_test.go @@ -0,0 +1,184 @@ +// Copyright 2022 Gravitational, Inc +// +// 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 tracing + +import ( + "bufio" + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/require" + commonv1 "go.opentelemetry.io/proto/otlp/common/v1" + resourcev1 "go.opentelemetry.io/proto/otlp/resource/v1" + otlp "go.opentelemetry.io/proto/otlp/trace/v1" + "google.golang.org/protobuf/encoding/protojson" +) + +func TestRotatingFileClient(t *testing.T) { + t.Parallel() + + // create a span to test with + span := &otlp.ResourceSpans{ + Resource: &resourcev1.Resource{ + Attributes: []*commonv1.KeyValue{ + { + Key: "test", + Value: &commonv1.AnyValue{ + Value: &commonv1.AnyValue_IntValue{ + IntValue: 0, + }, + }, + }, + }, + }, + ScopeSpans: []*otlp.ScopeSpans{ + { + Spans: []*otlp.Span{ + { + TraceId: []byte{1, 2, 3, 4}, + SpanId: []byte{5, 6, 7, 8}, + TraceState: "", + ParentSpanId: []byte{9, 10, 11, 12}, + Name: "test", + Kind: otlp.Span_SPAN_KIND_CLIENT, + StartTimeUnixNano: uint64(time.Now().Add(-1 * time.Minute).Unix()), + EndTimeUnixNano: uint64(time.Now().Unix()), + Attributes: []*commonv1.KeyValue{ + { + Key: "test", + Value: &commonv1.AnyValue{ + Value: &commonv1.AnyValue_IntValue{ + IntValue: 0, + }, + }, + }, + }, + Status: &otlp.Status{ + Message: "success!", + Code: otlp.Status_STATUS_CODE_OK, + }, + }, + }, + }, + }, + } + + const uploadCount = 10 + testSpans := []*otlp.ResourceSpans{span, span, span} + + cases := []struct { + name string + limit uint64 + filesCreated int + }{ + { + name: "small limit forces rotations", + limit: 10, + filesCreated: uploadCount * len(testSpans), + }, + { + name: "larger limit has no rotations", + limit: DefaultFileLimit, + filesCreated: 1, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + client, err := NewRotatingFileClient(dir, tt.limit) + require.NoError(t, err) + + // verify that creating the client creates a file + entries, err := os.ReadDir(dir) + require.NoError(t, err) + require.Len(t, entries, 1) + + // upload spans a bunch of spans + for i := 0; i < uploadCount; i++ { + require.NoError(t, client.UploadTraces(context.Background(), testSpans)) + } + + // stop the client to close and flush the files + require.NoError(t, client.Stop(context.Background())) + + // ensure that if we try to upload more spans we get back ErrShutdown + err = client.UploadTraces(context.Background(), testSpans) + require.ErrorIs(t, err, ErrShutdown) + + // get the names of all the files created and verify that files were rotated + entries, err = os.ReadDir(dir) + require.NoError(t, err) + require.Len(t, entries, tt.filesCreated) + + // read in all the spans that we just exported + var spans []*otlp.ResourceSpans + for _, entry := range entries { + spans = append(spans, readFileTraces(t, filepath.Join(dir, entry.Name()))...) + } + + // ensure that the number read matches the number of spans we uploaded + require.Len(t, spans, uploadCount*len(testSpans)) + + // confirm that all spans are equivalent to our test span + for _, fileSpan := range spans { + require.Empty(t, cmp.Diff(span, fileSpan, + cmpopts.IgnoreUnexported( + otlp.ResourceSpans{}, + otlp.ScopeSpans{}, + otlp.Span{}, + otlp.Status{}, + resourcev1.Resource{}, + commonv1.KeyValue{}, + commonv1.AnyValue{}, + ), + )) + } + }) + } +} + +func readFileTraces(t *testing.T, filename string) []*otlp.ResourceSpans { + t.Helper() + + f, err := os.Open(filename) + require.NoError(t, err) + defer func() { + require.NoError(t, f.Close()) + }() + + scanner := bufio.NewScanner(f) + scanner.Split(bufio.ScanLines) + + var spans []*otlp.ResourceSpans + for scanner.Scan() { + var span otlp.ResourceSpans + require.NoError(t, protojson.Unmarshal(scanner.Bytes(), &span)) + + spans = append(spans, &span) + + } + + require.NoError(t, scanner.Err()) + + return spans +} diff --git a/lib/observability/tracing/exporter.go b/lib/observability/tracing/exporter.go new file mode 100644 index 0000000000000..28f48ccaf4553 --- /dev/null +++ b/lib/observability/tracing/exporter.go @@ -0,0 +1,74 @@ +// Copyright 2022 Gravitational, Inc +// +// 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 tracing + +import ( + "context" + "errors" + "io" + + "github.com/gravitational/trace" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +// NewExporter returns a new exporter that is configured per the provided Config. +func NewExporter(ctx context.Context, cfg Config) (sdktrace.SpanExporter, error) { + if err := cfg.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + traceClient, err := NewClient(cfg) + if err != nil { + return nil, trace.Wrap(err) + } + + ctx, cancel := context.WithTimeout(ctx, cfg.DialTimeout) + defer cancel() + exporter, err := otlptrace.New(ctx, traceClient) + switch { + case errors.Is(err, context.DeadlineExceeded): + return nil, trace.ConnectionProblem(err, "failed to connect to tracing exporter %s: %v", cfg.ExporterURL, err) + case err != nil: + return nil, trace.NewAggregate(err, traceClient.Stop(context.Background())) + } + + if cfg.Client == nil { + return exporter, nil + } + + return &wrappedExporter{ + exporter: exporter, + closer: cfg.Client, + }, nil +} + +// wrappedExporter is a sdktrace.SpanExporter wrapper that is used to ensure that any +// io.Closer that are provided to NewExporter are closed when the Exporter is +// Shutdown. +type wrappedExporter struct { + closer io.Closer + exporter sdktrace.SpanExporter +} + +// ExportSpans forwards the spans to the wrapped exporter. +func (w *wrappedExporter) ExportSpans(ctx context.Context, spans []sdktrace.ReadOnlySpan) error { + return w.exporter.ExportSpans(ctx, spans) +} + +// Shutdown shuts down the wrapped exporter and closes the client. +func (w *wrappedExporter) Shutdown(ctx context.Context) error { + return trace.NewAggregate(w.exporter.Shutdown(ctx), w.closer.Close()) +} diff --git a/lib/observability/tracing/tracing.go b/lib/observability/tracing/tracing.go index 25e45cc4f1cb9..592f238f3e5c9 100644 --- a/lib/observability/tracing/tracing.go +++ b/lib/observability/tracing/tracing.go @@ -17,9 +17,9 @@ package tracing import ( "context" "crypto/tls" - "errors" "net" "net/url" + "strings" "time" "github.com/gravitational/teleport" @@ -29,17 +29,11 @@ import ( "github.com/sirupsen/logrus" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.4.0" oteltrace "go.opentelemetry.io/otel/trace" - otlp "go.opentelemetry.io/proto/otlp/trace/v1" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" ) const ( @@ -103,154 +97,44 @@ func (c *Config) CheckAndSetDefaults() error { c.Logger = logrus.WithField(trace.Component, teleport.ComponentTracing) } - if c.Client == nil { - // first check if a network address is specified, if it was, default - // to using grpc. If provided a URL, ensure that it is valid - _, _, err := net.SplitHostPort(c.ExporterURL) - if err == nil { - c.exporterURL = &url.URL{ - Scheme: "grpc", - Host: c.ExporterURL, - } - } else { - exporterURL, err := url.Parse(c.ExporterURL) - if err != nil { - return trace.BadParameter("failed to parse exporter URL: %v", err) - } - c.exporterURL = exporterURL - } - } - - return nil -} - -var _ otlptrace.Client = (*noopClient)(nil) - -type noopClient struct{} - -func (n noopClient) Start(context.Context) error { - return nil -} - -func (n noopClient) Stop(context.Context) error { - return nil -} - -func (n noopClient) UploadTraces(context.Context, []*otlp.ResourceSpans) error { - return nil -} - -// NewNoopClient returns an oltptrace.Client that does nothing -func NewNoopClient() otlptrace.Client { - return &noopClient{} -} - -// NewStartedClient either returns the provided Config.Client or constructs -// a new client that is connected to the Config.ExporterURL with the -// appropriate TLS credentials. The client is started prior to returning to -// the caller. -func NewStartedClient(ctx context.Context, cfg Config) (otlptrace.Client, error) { - if err := cfg.CheckAndSetDefaults(); err != nil { - return nil, trace.Wrap(err) - } - - clt, err := NewClient(cfg) - if err != nil { - return nil, trace.Wrap(err) - } - - ctx, cancel := context.WithTimeout(ctx, cfg.DialTimeout) - defer cancel() - if err := clt.Start(ctx); err != nil { - return nil, trace.Wrap(err) + if c.Client != nil { + return nil } - return clt, nil -} - -// NewClient either returns the provided Config.Client or constructs -// a new client that is connected to the Config.ExporterURL with the -// appropriate TLS credentials. The returned client is not started, -// it will be started by the provider if passed to one. -func NewClient(cfg Config) (otlptrace.Client, error) { - if err := cfg.CheckAndSetDefaults(); err != nil { - return nil, trace.Wrap(err) - } - - if cfg.Client != nil { - return cfg.Client, nil - } - - var httpOptions []otlptracehttp.Option - grpcOptions := []otlptracegrpc.Option{otlptracegrpc.WithDialOption(grpc.WithBlock())} - - if cfg.TLSConfig != nil { - httpOptions = append(httpOptions, otlptracehttp.WithTLSClientConfig(cfg.TLSConfig.Clone())) - grpcOptions = append(grpcOptions, otlptracegrpc.WithTLSCredentials(credentials.NewTLS(cfg.TLSConfig.Clone()))) - } else { - httpOptions = append(httpOptions, otlptracehttp.WithInsecure()) - grpcOptions = append(grpcOptions, otlptracegrpc.WithInsecure()) + // first check if a network address is specified, if it was, default + // to using grpc. If provided a URL, ensure that it is valid + h, _, err := net.SplitHostPort(c.ExporterURL) + if err != nil || h == "file" { + exporterURL, err := url.Parse(c.ExporterURL) + if err != nil { + return trace.BadParameter("failed to parse exporter URL: %v", err) + } + c.exporterURL = exporterURL + return nil } - var traceClient otlptrace.Client - switch cfg.exporterURL.Scheme { - case "http", "https": - httpOptions = append(httpOptions, otlptracehttp.WithEndpoint(cfg.ExporterURL[len(cfg.exporterURL.Scheme)+3:])) - traceClient = otlptracehttp.NewClient(httpOptions...) - case "grpc": - grpcOptions = append(grpcOptions, otlptracegrpc.WithEndpoint(cfg.ExporterURL[len(cfg.exporterURL.Scheme)+3:])) - traceClient = otlptracegrpc.NewClient(grpcOptions...) - default: - return nil, trace.BadParameter("unsupported exporter scheme: %q", cfg.exporterURL.Scheme) + c.exporterURL = &url.URL{ + Scheme: "grpc", + Host: c.ExporterURL, } - - return traceClient, nil -} - -// wrappedExporter is a sdktrace.SpanExporter wrapper that is used to ensure that any -// tracing.Client that are provided to NewExporter are closed when the Exporter is -// Shutdown. This is required because the tracing.Client ownership is transferred to -// the Exporter, which means we need to ensure it is closed. -type wrappedExporter struct { - client *tracing.Client - exporter *otlptrace.Exporter -} - -// ExportSpans forwards the spans to the wrapped exporter. -func (w *wrappedExporter) ExportSpans(ctx context.Context, spans []sdktrace.ReadOnlySpan) error { - return w.exporter.ExportSpans(ctx, spans) + return nil } -// Shutdown shuts down the wrapped exporter and closes the client. -func (w *wrappedExporter) Shutdown(ctx context.Context) error { - return trace.NewAggregate(w.exporter.Shutdown(ctx), w.client.Close()) -} +// Endpoint provides the properly formatted endpoint that the otlp client libraries +// are expecting. +func (c *Config) Endpoint() string { + uri := *c.exporterURL -// NewExporter returns a new exporter that is configured per the provided Config. -func NewExporter(ctx context.Context, cfg Config) (sdktrace.SpanExporter, error) { - traceClient, err := NewClient(cfg) - if err != nil { - return nil, trace.Wrap(err) - } - - ctx, cancel := context.WithTimeout(ctx, cfg.DialTimeout) - defer cancel() - exporter, err := otlptrace.New(ctx, traceClient) - switch { - case errors.Is(err, context.DeadlineExceeded): - return nil, trace.ConnectionProblem(err, "failed to connect to tracing exporter %s: %v", cfg.ExporterURL, err) - case err != nil: - return nil, trace.NewAggregate(err, traceClient.Stop(context.Background())) + if uri.Scheme == "file" { + uri.RawQuery = "" } + uri.Scheme = "" - if cfg.Client == nil { - return exporter, nil + s := uri.String() + if strings.HasPrefix(s, "//") { + return s[2:] } - - return &wrappedExporter{ - exporter: exporter, - client: cfg.Client, - }, nil + return s } // Provider wraps the OpenTelemetry tracing provider to provide common tags for all tracers. @@ -278,9 +162,11 @@ func (p *Provider) Shutdown(ctx context.Context) error { // NoopProvider creates a new Provider that never samples any spans. func NoopProvider() *Provider { - return &Provider{provider: sdktrace.NewTracerProvider( - sdktrace.WithSampler(sdktrace.NeverSample()), - )} + return &Provider{ + provider: sdktrace.NewTracerProvider( + sdktrace.WithSampler(sdktrace.NeverSample()), + ), + } } // NoopTracer creates a new Tracer that never samples any spans. @@ -334,11 +220,13 @@ func NewTraceProvider(ctx context.Context, cfg Config) (*Provider, error) { })) // set global provider to our provider wrapper to have all tracers use the common TracerOptions - provider := &Provider{provider: sdktrace.NewTracerProvider( - sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(cfg.SamplingRate))), - sdktrace.WithResource(res), - sdktrace.WithSpanProcessor(sdktrace.NewBatchSpanProcessor(exporter)), - )} + provider := &Provider{ + provider: sdktrace.NewTracerProvider( + sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(cfg.SamplingRate))), + sdktrace.WithResource(res), + sdktrace.WithSpanProcessor(sdktrace.NewBatchSpanProcessor(exporter)), + ), + } otel.SetTracerProvider(provider) return provider, nil diff --git a/lib/observability/tracing/tracing_test.go b/lib/observability/tracing/tracing_test.go index 03f2392bd0786..39b9dc413c11e 100644 --- a/lib/observability/tracing/tracing_test.go +++ b/lib/observability/tracing/tracing_test.go @@ -218,11 +218,23 @@ func TestNewExporter(t *testing.T) { errAssertion: require.NoError, exporterAssertion: require.NotNil, }, + { + name: "file exporter", + config: Config{ + Service: "test", + ExporterURL: "file://" + t.TempDir(), + }, + errAssertion: require.NoError, + exporterAssertion: require.NotNil, + }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { exporter, err := NewExporter(context.Background(), tt.config) + if exporter != nil { + t.Cleanup(func() { require.NoError(t, exporter.Shutdown(context.Background())) }) + } tt.errAssertion(t, err) tt.exporterAssertion(t, exporter) }) @@ -476,6 +488,26 @@ func TestConfig_CheckAndSetDefaults(t *testing.T) { Host: "localhost:8080", }, }, + { + name: "file exporter", + cfg: Config{ + Service: "test", + SamplingRate: 1.0, + ExporterURL: "file:///var/lib/teleport", + DialTimeout: time.Millisecond, + }, + errorAssertion: require.NoError, + expectedCfg: Config{ + Service: "test", + ExporterURL: "file:///var/lib/teleport", + SamplingRate: 1.0, + DialTimeout: time.Millisecond, + }, + expectedURL: &url.URL{ + Scheme: "file", + Host: "/var/lib/teleport", + }, + }, } for _, tt := range cases { @@ -493,3 +525,75 @@ func TestConfig_CheckAndSetDefaults(t *testing.T) { }) } } + +func TestConfig_Endpoint(t *testing.T) { + cases := []struct { + name string + cfg Config + expected string + }{ + { + name: "with http scheme", + cfg: Config{ + Service: "test", + ExporterURL: "http://localhost:8080", + }, + expected: "localhost:8080", + }, + { + name: "with https scheme", + cfg: Config{ + Service: "test", + ExporterURL: "https://localhost:8080/custom", + }, + expected: "localhost:8080/custom", + }, + { + name: "with grpc scheme", + cfg: Config{ + Service: "test", + ExporterURL: "grpc://collector.opentelemetry.svc:4317", + SamplingRate: 1.0, + DialTimeout: time.Millisecond, + }, + expected: "collector.opentelemetry.svc:4317", + }, + { + name: "without a scheme", + cfg: Config{ + Service: "test", + ExporterURL: "collector.opentelemetry.svc:4317", + SamplingRate: 1.0, + DialTimeout: time.Millisecond, + }, + expected: "collector.opentelemetry.svc:4317", + }, + { + name: "file exporter", + cfg: Config{ + Service: "test", + ExporterURL: "file:///var/lib/teleport", + SamplingRate: 1.0, + DialTimeout: time.Millisecond, + }, + expected: "/var/lib/teleport", + }, + { + name: "file exporter with limit", + cfg: Config{ + Service: "test", + ExporterURL: "file:///var/lib/teleport?limit=200", + SamplingRate: 1.0, + DialTimeout: time.Millisecond, + }, + expected: "/var/lib/teleport", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + require.NoError(t, tt.cfg.CheckAndSetDefaults()) + require.Equal(t, tt.expected, tt.cfg.Endpoint()) + }) + } +}