diff --git a/internal/otel/metrics.go b/internal/otel/metrics.go index 6fc0cad363..5672157237 100644 --- a/internal/otel/metrics.go +++ b/internal/otel/metrics.go @@ -27,7 +27,8 @@ import ( // Units are encoded according to the case-sensitive abbreviations from the // Unified Code for Units of Measure: http://unitsofmeasure.org/ucum.html. const ( - unitMilliseconds = "ms" + unitDimensionless = "1" + unitMilliseconds = "ms" ) var ( @@ -108,3 +109,40 @@ func LatencyMeasure(pkg string, provider string) metric.Float64Histogram { return m } + +func DimensionlessMeasure(pkg string, provider string, meterName string, description string) metric.Int64Counter { + + attrs := []attribute.KeyValue{ + packageKey.String(pkg), + providerKey.String(provider), + } + + pkgMeter := otel.Meter(pkg, metric.WithInstrumentationAttributes(attrs...)) + + m, err := pkgMeter.Int64Counter(pkg+meterName, metric.WithDescription(description), metric.WithUnit(unitDimensionless)) + + if err != nil { + // The only possible errors are from invalid key or value names, + // and those are programming errors that will be found during testing. + panic(fmt.Sprintf("fullName=%q, provider=%q: %v", pkg, pkgMeter, err)) + } + return m +} + +func CounterView(pkg string, meterName string, description string) []sdkmetric.View { + return []sdkmetric.View{ + // View for gauge counts. + func(inst sdkmetric.Instrument) (sdkmetric.Stream, bool) { + if inst.Kind == sdkmetric.InstrumentKindCounter { + if inst.Name == pkg+meterName { + return sdkmetric.Stream{ + Name: inst.Name, + Description: description, + Aggregation: sdkmetric.DefaultAggregationSelector(sdkmetric.InstrumentKindCounter), + }, true + } + } + return sdkmetric.Stream{}, false + }, + } +} diff --git a/internal/testing/oteltest/diff.go b/internal/testing/oteltest/diff.go index fab9f7a142..44264e0e3d 100644 --- a/internal/testing/oteltest/diff.go +++ b/internal/testing/oteltest/diff.go @@ -68,7 +68,7 @@ func formatCall(c *Call) string { // want is the list of expected calls. func Diff(gotSpans []sdktrace.ReadOnlySpan, gotMetrics []metricdata.ScopeMetrics, namePrefix, provider string, want []Call) string { ds := diffSpans(gotSpans, namePrefix, want) - dc := diffCounts(gotMetrics, namePrefix, provider, want) + dc := DiffMetrics(gotMetrics, namePrefix, provider, want) if len(ds) > 0 { ds = "trace: " + ds + "\n" } @@ -117,7 +117,7 @@ func diffSpans(got []sdktrace.ReadOnlySpan, prefix string, want []Call) string { return strings.Join(diffs, "\n") } -func diffCounts(got []metricdata.ScopeMetrics, prefix, provider string, wantCalls []Call) string { +func DiffMetrics(got []metricdata.ScopeMetrics, prefix, provider string, wantCalls []Call) string { // OTel metric data is structured. We need to iterate through it to find the // relevant metric data points and their attributes. var diffs []string @@ -125,7 +125,7 @@ func diffCounts(got []metricdata.ScopeMetrics, prefix, provider string, wantCall // Helper to convert attribute.Set to a canonical string key attrSetToCanonicalString := func(set attribute.Set) string { - // Get key-value pairs, sort them, and format into a stable string + // Get key-value pairs, sort them, and format into a stable string. attrs := make([]attribute.KeyValue, 0, set.Len()) iter := set.Iter() for iter.Next() { @@ -143,45 +143,69 @@ func diffCounts(got []metricdata.ScopeMetrics, prefix, provider string, wantCall return strings.Join(parts, ",") } - // Iterate through all collected metrics to find relevant data points - for _, sm := range got { + // Helper function to collect relevant attributes for tag comparison. + processAtrributes := func(attrSets ...attribute.Set) { + + var requiredAttributes []attribute.KeyValue + for _, attrSet := range attrSets { + for _, a := range attrSet.ToSlice() { + if a.Key == providerKey { + requiredAttributes = append(requiredAttributes, a) + } + + if a.Key == methodKey { + requiredAttributes = append(requiredAttributes, a) + } - providerVal, providerOK := sm.Scope.Attributes.Value(providerKey) + if a.Key == statusKey { + requiredAttributes = append(requiredAttributes, a) + } + } + } + + if len(requiredAttributes) > 0 { + gotTags[attrSetToCanonicalString(attribute.NewSet(requiredAttributes...))] = true + } + + } + + // Iterate through all collected metrics to find relevant data points. + for _, sm := range got { for _, m := range sm.Metrics { - // gocloud usually records counts. Check for Sum metrics. - if sum, ok := m.Data.(metricdata.Sum[float64]); ok { - for _, dp := range sum.DataPoints { - methodVal, methodOK := dp.Attributes.Value(methodKey) - statusVal, statusOK := dp.Attributes.Value(statusKey) - - if providerOK && methodOK && statusOK { - - attrSet := attribute.NewSet( - providerKey.String(providerVal.AsString()), - methodKey.String(methodVal.AsString()), - statusKey.String(statusVal.AsString()), - ) - - gotTags[attrSetToCanonicalString(attrSet)] = true - } + + // Using a switch will allow us accommodate other types of metrics. + switch v := m.Data.(type) { + case metricdata.Sum[int64]: + // Handle int64 Sum metrics. + for _, dp := range v.DataPoints { + processAtrributes(sm.Scope.Attributes, dp.Attributes) } + case metricdata.Sum[float64]: + // gocloud usually records counts. Check for Sum metrics. + for _, dp := range v.DataPoints { + processAtrributes(sm.Scope.Attributes, dp.Attributes) + } + default: + // Handle any other types of metrics. + processAtrributes(sm.Scope.Attributes) } } } - // Check that each wanted call has a corresponding metric data point with the correct attributes + // Check that each wanted call has a corresponding metric data point with the correct attributes. for _, wc := range wantCalls { - // Construct the expected set of attributes for the wanted call - expectedAttributes := attribute.NewSet( - methodKey.String(prefix+"."+wc.Method), - providerKey.String(provider), - // gcerrors code is usually formatted as a string status in the attribute - statusKey.String(fmt.Sprint(wc.Code)), - ) - - // Canonicalize the expected attributes to check against the collected ones - expectedKey := attrSetToCanonicalString(expectedAttributes) + // Construct the expected set of attributes for the wanted call. + expectedAttributes := []attribute.KeyValue{providerKey.String(provider)} + + if wc.Method != "" { + expectedAttributes = append(expectedAttributes, + methodKey.String(prefix+"."+wc.Method), + statusKey.String(fmt.Sprint(wc.Code))) + } + + // Canonicalize the expected attributes to check against the collected ones. + expectedKey := attrSetToCanonicalString(attribute.NewSet(expectedAttributes...)) if !gotTags[expectedKey] { diffs = append(diffs, fmt.Sprintf("missing metric data point with attributes %q", expectedKey)) diff --git a/runtimevar/etcdvar/go.mod b/runtimevar/etcdvar/go.mod index ac71f0d551..ff577a38f0 100644 --- a/runtimevar/etcdvar/go.mod +++ b/runtimevar/etcdvar/go.mod @@ -48,7 +48,6 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/go-replayers/grpcreplay v1.3.0 // indirect @@ -62,7 +61,6 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect go.etcd.io/etcd/api/v3 v3.5.21 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.21 // indirect - go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.37.0 // indirect diff --git a/runtimevar/etcdvar/go.sum b/runtimevar/etcdvar/go.sum index 3570a86727..bc7352943d 100644 --- a/runtimevar/etcdvar/go.sum +++ b/runtimevar/etcdvar/go.sum @@ -143,8 +143,6 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= @@ -252,14 +250,9 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -285,8 +278,6 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw= diff --git a/runtimevar/oc_test.go b/runtimevar/otel_test.go similarity index 59% rename from runtimevar/oc_test.go rename to runtimevar/otel_test.go index cfd974ecd6..f008a69c3b 100644 --- a/runtimevar/oc_test.go +++ b/runtimevar/otel_test.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Go Cloud Development Kit Authors +// Copyright 2019-2025 The Go Cloud Development Kit Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,19 +16,22 @@ package runtimevar_test import ( "context" - "testing" - - "go.opencensus.io/stats/view" - "gocloud.dev/internal/oc" - "gocloud.dev/internal/testing/octest" + "gocloud.dev/gcerrors" + "gocloud.dev/internal/testing/oteltest" "gocloud.dev/runtimevar" "gocloud.dev/runtimevar/constantvar" + "testing" ) -func TestOpenCensus(t *testing.T) { +const ( + pkgName = "gocloud.dev/runtimevar" + driver = "gocloud.dev/runtimevar/constantvar" +) + +func TestOpenTelemetry(t *testing.T) { ctx := context.Background() - te := octest.NewTestExporter(runtimevar.OpenCensusViews) - defer te.Unregister() + te := oteltest.NewTestExporter(t, runtimevar.OpenTelemetryViews) + defer te.Shutdown(ctx) v := constantvar.New(1) defer v.Close() @@ -39,18 +42,13 @@ func TestOpenCensus(t *testing.T) { cancel() _, _ = v.Watch(cctx) - seen := false - const driver = "gocloud.dev/runtimevar/constantvar" - for _, row := range te.Counts() { - if _, ok := row.Data.(*view.CountData); !ok { - continue - } - if row.Tags[0].Key == oc.ProviderKey && row.Tags[0].Value == driver { - seen = true - break - } - } - if !seen { - t.Errorf("did not see count row with provider=%s", driver) + // Check metrics - during migration, we may need to look for different metric names. + metrics := te.GetMetrics(ctx) + + diff := oteltest.DiffMetrics(metrics, pkgName, driver, []oteltest.Call{ + {Method: "", Code: gcerrors.OK}, + }) + if diff != "" { + t.Error(diff) } } diff --git a/runtimevar/runtimevar.go b/runtimevar/runtimevar.go index 1e7ee2b179..b423429b90 100644 --- a/runtimevar/runtimevar.go +++ b/runtimevar/runtimevar.go @@ -18,16 +18,16 @@ // // See https://gocloud.dev/howto/runtimevar/ for a detailed how-to guide. // -// # OpenCensus Integration +// # OpenTelemetry Integration // -// OpenCensus supports tracing and metric collection for multiple languages and -// backend providers. See https://opencensus.io. +// OpenTelemetry supports tracing and metric collection for multiple languages and +// backend providers. See https://opentelemetry.io. // -// This API collects an OpenCensus metric "gocloud.dev/runtimevar/value_changes", +// This API collects an OpenTelemetry metric "gocloud.dev/runtimevar/value_changes", // a count of the number of times all variables have changed values, by driver. // -// To enable metric collection in your application, see "Exporting stats" at -// https://opencensus.io/quickstart/go/metrics. +// To enable metric collection in your application, see the OpenTelemetry documentation at +// https://opentelemetry.io/docs/instrumentation/go/getting-started/ package runtimevar // import "gocloud.dev/runtimevar" import ( @@ -44,14 +44,13 @@ import ( "sync" "time" - "go.opencensus.io/stats" - "go.opencensus.io/stats/view" - "go.opencensus.io/tag" + "go.opentelemetry.io/otel/metric" "gocloud.dev/internal/gcerr" - "gocloud.dev/internal/oc" "gocloud.dev/internal/openurl" "gocloud.dev/runtimevar/driver" "gocloud.dev/secrets" + + gcdkotel "gocloud.dev/internal/otel" ) // Snapshot contains a snapshot of a variable's value and metadata about it. @@ -81,25 +80,15 @@ func (s *Snapshot) As(i any) bool { const pkgName = "gocloud.dev/runtimevar" var ( - changeMeasure = stats.Int64(pkgName+"/value_changes", "Count of variable value changes", - stats.UnitDimensionless) - // OpenCensusViews are predefined views for OpenCensus metrics. - OpenCensusViews = []*view.View{ - { - Name: pkgName + "/value_changes", - Measure: changeMeasure, - Description: "Count of variable value changes by driver.", - TagKeys: []tag.Key{oc.ProviderKey}, - Aggregation: view.Count(), - }, - } + OpenTelemetryViews = gcdkotel.CounterView(pkgName, "/value_changes", + "Count of variable value changes by driver.") ) // Variable provides an easy and portable way to watch runtime configuration // variables. To create a Variable, use constructors found in driver subpackages. type Variable struct { - dw driver.Watcher - provider string // for metric collection; refers to driver package name + dw driver.Watcher + changeMeasure metric.Int64Counter // For cancelling the background goroutine, and noticing when it has exited. backgroundCancel context.CancelFunc @@ -126,9 +115,13 @@ var New = newVar func newVar(w driver.Watcher) *Variable { ctx, cancel := context.WithCancel(context.Background()) changed := make(chan struct{}) + + providerName := gcdkotel.ProviderName(w) + v := &Variable{ - dw: w, - provider: oc.ProviderName(w), + dw: w, + changeMeasure: gcdkotel.DimensionlessMeasure(pkgName, providerName, "/value_changes", + "Count of variable value changes by driver"), backgroundCancel: cancel, backgroundDone: make(chan struct{}), haveGoodCh: make(chan struct{}), @@ -173,7 +166,7 @@ func (c *Variable) Watch(ctx context.Context) (Snapshot, error) { } c.mu.Lock() defer c.mu.Unlock() - if c.lastErr == ErrClosed { + if errors.Is(c.lastErr, ErrClosed) { return Snapshot{}, ErrClosed } else if ctxErr != nil { return Snapshot{}, ctxErr @@ -203,12 +196,12 @@ func (c *Variable) background(ctx context.Context) { // There's something new to return! prevState = curState - _ = stats.RecordWithTags(ctx, []tag.Mutator{tag.Upsert(oc.ProviderKey, c.provider)}, changeMeasure.M(1)) + c.changeMeasure.Add(ctx, 1) // Error from RecordWithTags is not possible. // Updates under the lock. c.mu.Lock() - if c.lastErr == ErrClosed { + if errors.Is(c.lastErr, ErrClosed) { close(c.backgroundDone) c.mu.Unlock() return @@ -267,7 +260,7 @@ func (c *Variable) Latest(ctx context.Context) (Snapshot, error) { } c.mu.RLock() defer c.mu.RUnlock() - if haveGood && c.lastErr != ErrClosed { + if haveGood && !errors.Is(c.lastErr, ErrClosed) { return c.lastGood, nil } return Snapshot{}, c.lastErr @@ -279,7 +272,7 @@ func (c *Variable) CheckHealth() error { haveGood := c.haveGood() c.mu.RLock() defer c.mu.RUnlock() - if haveGood && c.lastErr != ErrClosed { + if haveGood && !errors.Is(c.lastErr, ErrClosed) { return nil } return c.lastErr @@ -289,7 +282,7 @@ func (c *Variable) CheckHealth() error { func (c *Variable) Close() error { // Record that we're closing. Subsequent calls to Watch/Latest will return ErrClosed. c.mu.Lock() - if c.lastErr == ErrClosed { + if errors.Is(c.lastErr, ErrClosed) { c.mu.Unlock() return ErrClosed }