diff --git a/elastictransport/elastictransport.go b/elastictransport/elastictransport.go index 8dfee6b..4934d29 100644 --- a/elastictransport/elastictransport.go +++ b/elastictransport/elastictransport.go @@ -51,6 +51,11 @@ type Interface interface { Perform(*http.Request) (*http.Response, error) } +// Instrumented allows to retrieve the current transport Instrumentation +type Instrumented interface { + InstrumentationEnabled() Instrumentation +} + // Config represents the configuration of HTTP client. type Config struct { UserAgent string @@ -87,6 +92,8 @@ type Config struct { EnableMetrics bool EnableDebugLogger bool + Instrumentation Instrumentation + DiscoverNodesInterval time.Duration Transport http.RoundTripper @@ -125,6 +132,8 @@ type Client struct { compressRequestBody bool compressRequestBodyLevel int + instrumentation Instrumentation + metrics *metrics transport http.RoundTripper @@ -225,6 +234,8 @@ func New(cfg Config) (*Client, error) { logger: cfg.Logger, selector: cfg.Selector, poolFunc: cfg.ConnectionPoolFunc, + + instrumentation: cfg.Instrumentation, } if client.poolFunc != nil { @@ -391,6 +402,10 @@ func (c *Client) Perform(req *http.Request) (*http.Response, error) { c.metrics.Unlock() } + if res != nil && c.instrumentation != nil { + c.instrumentation.AfterResponse(req.Context(), res) + } + // Retry on configured response statuses if res != nil && !c.disableRetry { for _, code := range c.retryOnStatus { @@ -445,6 +460,10 @@ func (c *Client) URLs() []*url.URL { return c.pool.URLs() } +func (c *Client) InstrumentationEnabled() Instrumentation { + return c.instrumentation +} + func (c *Client) setReqURL(u *url.URL, req *http.Request) *http.Request { req.URL.Scheme = u.Scheme req.URL.Host = u.Host diff --git a/elastictransport/instrumentation.go b/elastictransport/instrumentation.go new file mode 100644 index 0000000..2a6854c --- /dev/null +++ b/elastictransport/instrumentation.go @@ -0,0 +1,215 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you 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 elastictransport + +import ( + "bytes" + "context" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "io" + "net/http" + "strconv" +) + +const schemaUrl = "https://opentelemetry.io/schemas/1.21.0" +const tracerName = "elasticsearch-api" + +// Constants for Semantic Convention +// see https://opentelemetry.io/docs/specs/semconv/database/elasticsearch/ for details. +const attrDbSystem = "db.system" +const attrDbStatement = "db.statement" +const attrDbOperation = "db.operation" +const attrDbElasticsearchClusterName = "db.elasticsearch.cluster.name" +const attrDbElasticsearchNodeName = "db.elasticsearch.node.name" +const attrHttpRequestMethod = "http.request.method" +const attrUrlFull = "url.full" +const attrServerAddress = "server.address" +const attrServerPort = "server.port" +const attrPathParts = "db.elasticsearch.path_parts." + +// Instrumentation defines the interface the client uses to propagate information about the requests. +// Each method is called with the current context or request for propagation. +type Instrumentation interface { + // Start creates the span before building the request, returned context will be propagated to the request by the client. + Start(ctx context.Context, name string) context.Context + + // Close will be called once the client has returned. + Close(ctx context.Context) + + // RecordError propagates an error. + RecordError(ctx context.Context, err error) + + // RecordPathPart provides the path variables, called once per variable in the url. + RecordPathPart(ctx context.Context, pathPart, value string) + + // RecordRequestBody provides the endpoint name as well as the current request payload. + RecordRequestBody(ctx context.Context, endpoint string, query io.Reader) io.ReadCloser + + // BeforeRequest provides the request and endpoint name, called before sending to the server. + BeforeRequest(req *http.Request, endpoint string) + + // AfterRequest provides the request, system used (e.g. elasticsearch) and endpoint name. + // Called after the request has been enhanced with the information from the transport and sent to the server. + AfterRequest(req *http.Request, system, endpoint string) + + // AfterResponse provides the response. + AfterResponse(ctx context.Context, res *http.Response) +} + +type ElasticsearchOpenTelemetry struct { + tracer trace.Tracer + recordBody bool +} + +// NewOtelInstrumentation returns a new instrument for Open Telemetry traces +// If no provider is passed, the instrumentation will fall back to the global otel provider. +// captureSearchBody sets the query capture behavior for search endpoints. +// version should be set to the version provided by the caller. +func NewOtelInstrumentation(provider trace.TracerProvider, captureSearchBody bool, version string) *ElasticsearchOpenTelemetry { + if provider == nil { + provider = otel.GetTracerProvider() + } + return &ElasticsearchOpenTelemetry{ + tracer: provider.Tracer( + tracerName, + trace.WithInstrumentationVersion(version), + trace.WithSchemaURL(schemaUrl), + ), + recordBody: captureSearchBody, + } +} + +// Start begins a new span in the given context with the provided name. +// Span will always have a kind set to trace.SpanKindClient. +// The context span aware is returned for use within the client. +func (i ElasticsearchOpenTelemetry) Start(ctx context.Context, name string) context.Context { + newCtx, _ := i.tracer.Start(ctx, name, trace.WithSpanKind(trace.SpanKindClient)) + return newCtx +} + +// Close call for the end of the span, preferably defered by the client once started. +func (i ElasticsearchOpenTelemetry) Close(ctx context.Context) { + span := trace.SpanFromContext(ctx) + if span.IsRecording() { + span.End() + } +} + +// shouldRecordRequestBody filters for search endpoints. +func (i ElasticsearchOpenTelemetry) shouldRecordRequestBody(endpoint string) bool { + // allow list of endpoints that will propagate query to OpenTelemetry. + // see https://opentelemetry.io/docs/specs/semconv/database/elasticsearch/#call-level-attributes + var searchEndpoints = map[string]struct{}{ + "search": {}, + "async_search.submit": {}, + "msearch": {}, + "eql.search": {}, + "terms_enum": {}, + "search_template": {}, + "msearch_template": {}, + "render_search_template": {}, + } + + if i.recordBody { + if _, ok := searchEndpoints[endpoint]; ok { + return true + } + } + return false +} + +// RecordRequestBody add the db.statement attributes only for search endpoints. +// Returns a new reader if the query has been recorded, nil otherwise. +func (i ElasticsearchOpenTelemetry) RecordRequestBody(ctx context.Context, endpoint string, query io.Reader) io.ReadCloser { + if i.shouldRecordRequestBody(endpoint) == false { + return nil + } + + span := trace.SpanFromContext(ctx) + if span.IsRecording() { + buf := bytes.Buffer{} + buf.ReadFrom(query) + span.SetAttributes(attribute.String(attrDbStatement, buf.String())) + getBody := func() (io.ReadCloser, error) { + reader := buf + return io.NopCloser(&reader), nil + } + reader, _ := getBody() + return reader + } + + return nil +} + +// RecordError sets any provided error as an OTel error in the active span. +func (i ElasticsearchOpenTelemetry) RecordError(ctx context.Context, err error) { + span := trace.SpanFromContext(ctx) + if span.IsRecording() { + span.SetStatus(codes.Error, "an error happened while executing a request") + span.RecordError(err) + } +} + +// RecordPathPart sets the couple for a specific path part. +// An index placed in the path would translate to `db.elasticsearch.path_parts.index`. +func (i ElasticsearchOpenTelemetry) RecordPathPart(ctx context.Context, pathPart, value string) { + span := trace.SpanFromContext(ctx) + if span.IsRecording() { + span.SetAttributes(attribute.String(attrPathParts+pathPart, value)) + } +} + +// BeforeRequest noop for interface. +func (i ElasticsearchOpenTelemetry) BeforeRequest(req *http.Request, endpoint string) {} + +// AfterRequest enrich the span with the available data from the request. +func (i ElasticsearchOpenTelemetry) AfterRequest(req *http.Request, system, endpoint string) { + span := trace.SpanFromContext(req.Context()) + if span.IsRecording() { + span.SetAttributes( + attribute.String(attrDbSystem, system), + attribute.String(attrDbOperation, endpoint), + attribute.String(attrHttpRequestMethod, req.Method), + attribute.String(attrUrlFull, req.URL.String()), + attribute.String(attrServerAddress, req.URL.Hostname()), + ) + if value, err := strconv.ParseInt(req.URL.Port(), 10, 32); err == nil { + span.SetAttributes(attribute.Int64(attrServerPort, value)) + } + } +} + +// AfterResponse enric the span with the cluster id and node name if the query was executed on Elastic Cloud. +func (i ElasticsearchOpenTelemetry) AfterResponse(ctx context.Context, res *http.Response) { + span := trace.SpanFromContext(ctx) + if span.IsRecording() { + if id := res.Header.Get("X-Found-Handling-Cluster"); id != "" { + span.SetAttributes( + attribute.String(attrDbElasticsearchClusterName, id), + ) + } + if name := res.Header.Get("X-Found-Handling-Instance"); name != "" { + span.SetAttributes( + attribute.String(attrDbElasticsearchNodeName, name), + ) + } + } +} diff --git a/elastictransport/instrumentation_test.go b/elastictransport/instrumentation_test.go new file mode 100644 index 0000000..2be41a7 --- /dev/null +++ b/elastictransport/instrumentation_test.go @@ -0,0 +1,342 @@ +// Licensed to Elasticsearch B.V. under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Elasticsearch B.V. licenses this file to you 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 elastictransport + +import ( + "context" + "fmt" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" + "net/http" + "reflect" + "strings" + "testing" +) + +var spanName = "search" +var endpoint = spanName + +func NewTestOpenTelemetry() (*tracetest.InMemoryExporter, *sdktrace.TracerProvider, *ElasticsearchOpenTelemetry) { + exporter := tracetest.NewInMemoryExporter() + provider := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter)) + instrumentation := NewOtelInstrumentation(provider, true, "8.99.0-SNAPSHOT") + return exporter, provider, instrumentation +} + +func TestElasticsearchOpenTelemetry_StartClose(t *testing.T) { + t.Run("Valid Start name", func(t *testing.T) { + exporter, provider, instrument := NewTestOpenTelemetry() + + ctx := instrument.Start(context.Background(), spanName) + instrument.Close(ctx) + err := provider.ForceFlush(context.Background()) + if err != nil { + t.Fatal(err) + } + + if ctx == nil { + t.Fatalf("Start() returned an empty context") + } + + if len(exporter.GetSpans()) != 1 { + t.Fatalf("wrong number of spans recorded, got %v, want %v", len(exporter.GetSpans()), 1) + } + + span := exporter.GetSpans()[0] + + if span.Name != spanName { + t.Errorf("invalid span name, got %v, want %v", span.Name, spanName) + } + + if span.SpanKind != trace.SpanKindClient { + t.Errorf("wrong Span kind, expected, got %v, want %v", span.SpanKind, trace.SpanKindClient) + } + }) +} + +func TestElasticsearchOpenTelemetry_BeforeRequest(t *testing.T) { + t.Run("BeforeRequest noop", func(t *testing.T) { + _, _, instrument := NewTestOpenTelemetry() + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil) + if err != nil { + t.Fatalf("error while creating request") + } + snapshot := req.Clone(context.Background()) + instrument.BeforeRequest(req, endpoint) + + if !reflect.DeepEqual(req, snapshot) { + t.Fatalf("request should not have changed") + } + }) +} + +func TestElasticsearchOpenTelemetry_AfterRequest(t *testing.T) { + t.Run("AfterRequest", func(t *testing.T) { + exporter, provider, instrument := NewTestOpenTelemetry() + fullUrl := "http://elastic:elastic@localhost:9200/test-index/_search" + + ctx := instrument.Start(context.Background(), spanName) + req, err := http.NewRequestWithContext(ctx, http.MethodOptions, fullUrl, nil) + if err != nil { + t.Fatalf("error while creating request") + } + instrument.AfterRequest(req, "elasticsearch", endpoint) + instrument.Close(ctx) + err = provider.ForceFlush(context.Background()) + if err != nil { + t.Fatal(err) + } + + if len(exporter.GetSpans()) != 1 { + t.Fatalf("wrong number of spans recorded, got %v, want %v", len(exporter.GetSpans()), 1) + } + + span := exporter.GetSpans()[0] + + if span.Name != spanName { + t.Errorf("invalid span name, got %v, want %v", span.Name, spanName) + } + + for _, attribute := range span.Attributes { + switch attribute.Key { + case attrDbSystem: + if !attribute.Valid() && attribute.Value.AsString() != "elasticsearch" { + t.Errorf("invalid %v, got %v, want %v", attrDbSystem, attribute.Value.AsString(), "elasticsearch") + } + case attrDbOperation: + if !attribute.Valid() && attribute.Value.AsString() != endpoint { + t.Errorf("invalid %v, got %v, want %v", attrDbOperation, attribute.Value.AsString(), endpoint) + } + case attrHttpRequestMethod: + if !attribute.Valid() && attribute.Value.AsString() != http.MethodOptions { + t.Errorf("invalid %v, got %v, want %v", attrHttpRequestMethod, attribute.Value.AsString(), http.MethodOptions) + } + case attrUrlFull: + if !attribute.Valid() && attribute.Value.AsString() != fullUrl { + t.Errorf("invalid %v, got %v, want %v", attrUrlFull, attribute.Value.AsString(), fullUrl) + } + case attrServerAddress: + if !attribute.Valid() && attribute.Value.AsString() != "localhost" { + t.Errorf("invalid %v, got %v, want %v", attrServerAddress, attribute.Value.AsString(), "localhost") + } + case attrServerPort: + if !attribute.Valid() && attribute.Value.AsInt64() != 9200 { + t.Errorf("invalid %v, got %v, want %v", attrServerPort, attribute.Value.AsInt64(), 9200) + } + } + } + }) +} + +func TestElasticsearchOpenTelemetry_RecordError(t *testing.T) { + exporter, provider, instrument := NewTestOpenTelemetry() + + ctx := instrument.Start(context.Background(), spanName) + instrument.RecordError(ctx, fmt.Errorf("these are not the spans you are looking for")) + instrument.Close(ctx) + err := provider.ForceFlush(context.Background()) + if err != nil { + t.Fatal(err) + } + + if len(exporter.GetSpans()) != 1 { + t.Fatalf("wrong number of spans recorded, got %v, want %v", len(exporter.GetSpans()), 1) + } + + span := exporter.GetSpans()[0] + + if span.Name != spanName { + t.Errorf("invalid span name, got %v, want %v", span.Name, spanName) + } + + if span.Status.Code != codes.Error { + t.Errorf("expected the span to have a status.code Error, got %v, want %v", span.Status.Code, codes.Error) + } +} + +func TestElasticsearchOpenTelemetry_RecordClusterId(t *testing.T) { + exporter, provider, instrument := NewTestOpenTelemetry() + + ctx := instrument.Start(context.Background(), spanName) + clusterId := "randomclusterid" + instrument.AfterResponse(ctx, &http.Response{Header: map[string][]string{ + "X-Found-Handling-Cluster": {clusterId}, + }}) + instrument.Close(ctx) + err := provider.ForceFlush(context.Background()) + if err != nil { + t.Fatal(err) + } + + if len(exporter.GetSpans()) != 1 { + t.Fatalf("wrong number of spans recorded, got %v, want %v", len(exporter.GetSpans()), 1) + } + + span := exporter.GetSpans()[0] + + if span.Name != spanName { + t.Errorf("invalid span name, got %v, want %v", span.Name, spanName) + } + + for _, attribute := range span.Attributes { + switch attribute.Key { + case attrDbElasticsearchClusterName: + if !attribute.Valid() && attribute.Value.AsString() != clusterId { + t.Errorf("invalid %v, got %v, want %v", attrServerAddress, attribute.Value.AsString(), clusterId) + } + } + } +} + +func TestElasticsearchOpenTelemetry_RecordNodeName(t *testing.T) { + exporter, provider, instrument := NewTestOpenTelemetry() + + ctx := instrument.Start(context.Background(), spanName) + nodeName := "randomnodename" + instrument.AfterResponse(ctx, &http.Response{Header: map[string][]string{ + "X-Found-Handling-Instance": {nodeName}, + }}) + instrument.Close(ctx) + err := provider.ForceFlush(context.Background()) + if err != nil { + t.Fatal(err) + } + + if len(exporter.GetSpans()) != 1 { + t.Fatalf("wrong number of spans recorded, got %v, want %v", len(exporter.GetSpans()), 1) + } + + span := exporter.GetSpans()[0] + + if span.Name != spanName { + t.Errorf("invalid span name, got %v, want %v", span.Name, spanName) + } + + for _, attribute := range span.Attributes { + switch attribute.Key { + case attrDbElasticsearchNodeName: + if !attribute.Valid() && attribute.Value.AsString() != nodeName { + t.Errorf("invalid %v, got %v, want %v", attrDbElasticsearchNodeName, attribute.Value.AsString(), nodeName) + } + } + } +} + +func TestElasticsearchOpenTelemetry_RecordPathPart(t *testing.T) { + exporter, provider, instrument := NewTestOpenTelemetry() + indexName := "test-index" + pretty := "true" + + ctx := instrument.Start(context.Background(), spanName) + instrument.RecordPathPart(ctx, "index", indexName) + instrument.RecordPathPart(ctx, "pretty", pretty) + instrument.Close(ctx) + err := provider.ForceFlush(context.Background()) + if err != nil { + t.Fatal(err) + } + + if len(exporter.GetSpans()) != 1 { + t.Fatalf("wrong number of spans recorded, got %v, want %v", len(exporter.GetSpans()), 1) + } + + span := exporter.GetSpans()[0] + + if span.Name != spanName { + t.Errorf("invalid span name, got %v, want %v", span.Name, spanName) + } + + for _, attribute := range span.Attributes { + switch attribute.Key { + case attrPathParts + "index": + if !attribute.Valid() && attribute.Value.AsString() != indexName { + t.Errorf("invalid %v, got %v, want %v", attrPathParts+"index", attribute.Value.AsString(), indexName) + } + case attrPathParts + "pretty": + if !attribute.Valid() && attribute.Value.AsString() != indexName { + t.Errorf("invalid %v, got %v, want %v", attrPathParts+"pretty", attribute.Value.AsString(), indexName) + } + } + } +} + +func TestElasticsearchOpenTelemetry_RecordRequestBody(t *testing.T) { + exporter, provider, instrument := NewTestOpenTelemetry() + fullUrl := "http://elastic:elastic@localhost:9200/test-index/_search" + query := `{"query": {"match_all": {}}}` + + // Won't log query + ctx := instrument.Start(context.Background(), spanName) + _, err := http.NewRequestWithContext(ctx, http.MethodOptions, fullUrl, strings.NewReader(query)) + if err != nil { + t.Fatalf("error while creating request") + } + if reader := instrument.RecordRequestBody(ctx, "foo.endpoint", strings.NewReader(query)); reader != nil { + t.Errorf("returned reader should be nil") + } + instrument.Close(ctx) + + // Will log query + secondCtx := instrument.Start(context.Background(), spanName) + _, err = http.NewRequestWithContext(ctx, http.MethodOptions, fullUrl, strings.NewReader(query)) + if err != nil { + t.Fatalf("error while creating request") + } + if reader := instrument.RecordRequestBody(secondCtx, "search", strings.NewReader(query)); reader == nil { + t.Errorf("returned reader should not be nil") + } + instrument.Close(secondCtx) + + err = provider.ForceFlush(context.Background()) + if err != nil { + t.Fatal(err) + } + + if len(exporter.GetSpans()) != 2 { + t.Fatalf("wrong number of spans recorded, got %v, want %v", len(exporter.GetSpans()), 1) + } + + span := exporter.GetSpans()[0] + if span.Name != spanName { + t.Errorf("invalid span name, got %v, want %v", span.Name, spanName) + } + + for _, attribute := range span.Attributes { + switch attribute.Key { + case attrDbStatement: + t.Errorf("span should not have a %v entry", attrDbStatement) + } + } + + querySpan := exporter.GetSpans()[1] + if querySpan.Name != spanName { + t.Errorf("invalid span name, got %v, want %v", querySpan.Name, spanName) + } + + for _, attribute := range querySpan.Attributes { + switch attribute.Key { + case attrDbStatement: + if !attribute.Valid() && attribute.Value.AsString() != query { + t.Errorf("invalid query provided, got %v, want %v", attribute.Value.AsString(), query) + } + } + } +} diff --git a/elastictransport/version/version.go b/elastictransport/version/version.go index ff377c9..7970ce8 100644 --- a/elastictransport/version/version.go +++ b/elastictransport/version/version.go @@ -17,4 +17,4 @@ package version -const Transport = "8.3.0" +const Transport = "8.4.0" diff --git a/go.mod b/go.mod index 6bd09a9..4f3854b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,16 @@ module github.com/elastic/elastic-transport-go/v8 -go 1.13 \ No newline at end of file +go 1.20 + +require ( + go.opentelemetry.io/otel v1.21.0 + go.opentelemetry.io/otel/sdk v1.21.0 + go.opentelemetry.io/otel/trace v1.21.0 +) + +require ( + github.com/go-logr/logr v1.3.0 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + go.opentelemetry.io/otel/metric v1.21.0 // indirect + golang.org/x/sys v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..35bc18e --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= +go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= +go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= +go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= +go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8= +go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= +go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= +go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=