From a2e3e463c0fe2b5c0741519aa5b355a89f0609fc Mon Sep 17 00:00:00 2001 From: David Ashpole Date: Fri, 27 Oct 2023 12:56:15 -0400 Subject: [PATCH] Add exemplar support to OpenCensus bridge (#4585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add exemplar support to OpenCensus bridge * expand set of translated exemplar attributes --------- Co-authored-by: Robert PajÄ…k --- CHANGELOG.md | 1 + bridge/opencensus/doc.go | 1 - bridge/opencensus/internal/ocmetric/metric.go | 182 ++++++++- .../internal/ocmetric/metric_test.go | 347 +++++++++++++++++- 4 files changed, 518 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a91d8f99d1b..15c90b0c6c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add `Version` function in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc`. (#4660) - Add `Version` function in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp`. (#4660) - Add Summary, SummaryDataPoint, and QuantileValue to `go.opentelemetry.io/sdk/metric/metricdata`. (#4622) +- `go.opentelemetry.io/otel/bridge/opencensus.NewMetricProducer` now supports exemplars from OpenCensus. (#4585) ### Deprecated diff --git a/bridge/opencensus/doc.go b/bridge/opencensus/doc.go index ed2a4cfd935..df62b1c575e 100644 --- a/bridge/opencensus/doc.go +++ b/bridge/opencensus/doc.go @@ -59,5 +59,4 @@ // - Summary-typed metrics are dropped // - GaugeDistribution-typed metrics are dropped // - Histogram's SumOfSquaredDeviation field is dropped -// - Exemplars on Histograms are dropped package opencensus // import "go.opentelemetry.io/otel/bridge/opencensus" diff --git a/bridge/opencensus/internal/ocmetric/metric.go b/bridge/opencensus/internal/ocmetric/metric.go index d656e5d2871..aeb3479f40b 100644 --- a/bridge/opencensus/internal/ocmetric/metric.go +++ b/bridge/opencensus/internal/ocmetric/metric.go @@ -17,8 +17,13 @@ package internal // import "go.opentelemetry.io/otel/bridge/opencensus/internal/ import ( "errors" "fmt" + "math" + "reflect" + "sort" + "strconv" ocmetricdata "go.opencensus.io/metric/metricdata" + octrace "go.opencensus.io/trace" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/sdk/metric/metricdata" @@ -30,6 +35,7 @@ var ( errNegativeDistributionCount = errors.New("distribution count is negative") errNegativeBucketCount = errors.New("distribution bucket count is negative") errMismatchedAttributeKeyValues = errors.New("mismatched number of attribute keys and values") + errInvalidExemplarSpanContext = errors.New("span context exemplar attachment does not contain an OpenCensus SpanContext") ) // ConvertMetrics converts metric data from OpenCensus to OpenTelemetry. @@ -134,7 +140,7 @@ func convertHistogram(labelKeys []ocmetricdata.LabelKey, ts []*ocmetricdata.Time err = errors.Join(err, fmt.Errorf("%w: %d", errMismatchedValueTypes, p.Value)) continue } - bucketCounts, bucketErr := convertBucketCounts(dist.Buckets) + bucketCounts, exemplars, bucketErr := convertBuckets(dist.Buckets) if bucketErr != nil { err = errors.Join(err, bucketErr) continue @@ -143,7 +149,6 @@ func convertHistogram(labelKeys []ocmetricdata.LabelKey, ts []*ocmetricdata.Time err = errors.Join(err, fmt.Errorf("%w: %d", errNegativeDistributionCount, dist.Count)) continue } - // TODO: handle exemplars points = append(points, metricdata.HistogramDataPoint[float64]{ Attributes: attrs, StartTime: t.StartTime, @@ -152,22 +157,187 @@ func convertHistogram(labelKeys []ocmetricdata.LabelKey, ts []*ocmetricdata.Time Sum: dist.Sum, Bounds: dist.BucketOptions.Bounds, BucketCounts: bucketCounts, + Exemplars: exemplars, }) } } return metricdata.Histogram[float64]{DataPoints: points, Temporality: metricdata.CumulativeTemporality}, err } -// convertBucketCounts converts from OpenCensus bucket counts to slice of uint64. -func convertBucketCounts(buckets []ocmetricdata.Bucket) ([]uint64, error) { +// convertBuckets converts from OpenCensus bucket counts to slice of uint64, +// and converts OpenCensus exemplars to OpenTelemetry exemplars. +func convertBuckets(buckets []ocmetricdata.Bucket) ([]uint64, []metricdata.Exemplar[float64], error) { bucketCounts := make([]uint64, len(buckets)) + exemplars := []metricdata.Exemplar[float64]{} + var err error for i, bucket := range buckets { if bucket.Count < 0 { - return nil, fmt.Errorf("%w: %q", errNegativeBucketCount, bucket.Count) + err = errors.Join(err, fmt.Errorf("%w: %q", errNegativeBucketCount, bucket.Count)) + continue } bucketCounts[i] = uint64(bucket.Count) + + if bucket.Exemplar != nil { + exemplar, exemplarErr := convertExemplar(bucket.Exemplar) + if exemplarErr != nil { + err = errors.Join(err, exemplarErr) + continue + } + exemplars = append(exemplars, exemplar) + } + } + return bucketCounts, exemplars, err +} + +// convertExemplar converts an OpenCensus exemplar to an OpenTelemetry exemplar. +func convertExemplar(ocExemplar *ocmetricdata.Exemplar) (metricdata.Exemplar[float64], error) { + exemplar := metricdata.Exemplar[float64]{ + Value: ocExemplar.Value, + Time: ocExemplar.Timestamp, + } + var err error + for k, v := range ocExemplar.Attachments { + switch { + case k == ocmetricdata.AttachmentKeySpanContext: + sc, ok := v.(octrace.SpanContext) + if !ok { + err = errors.Join(err, fmt.Errorf("%w; type: %v", errInvalidExemplarSpanContext, reflect.TypeOf(v))) + continue + } + exemplar.SpanID = sc.SpanID[:] + exemplar.TraceID = sc.TraceID[:] + default: + exemplar.FilteredAttributes = append(exemplar.FilteredAttributes, convertKV(k, v)) + } + } + sortable := attribute.Sortable(exemplar.FilteredAttributes) + sort.Sort(&sortable) + return exemplar, err +} + +// convertKV converts an OpenCensus Attachment to an OpenTelemetry KeyValue. +func convertKV(key string, value any) attribute.KeyValue { + switch typedVal := value.(type) { + case bool: + return attribute.Bool(key, typedVal) + case int: + return attribute.Int(key, typedVal) + case int8: + return attribute.Int(key, int(typedVal)) + case int16: + return attribute.Int(key, int(typedVal)) + case int32: + return attribute.Int(key, int(typedVal)) + case int64: + return attribute.Int64(key, typedVal) + case uint: + return uintKV(key, typedVal) + case uint8: + return uintKV(key, uint(typedVal)) + case uint16: + return uintKV(key, uint(typedVal)) + case uint32: + return uintKV(key, uint(typedVal)) + case uintptr: + return uint64KV(key, uint64(typedVal)) + case uint64: + return uint64KV(key, uint64(typedVal)) + case float32: + return attribute.Float64(key, float64(typedVal)) + case float64: + return attribute.Float64(key, typedVal) + case complex64: + return attribute.String(key, complexToString(typedVal)) + case complex128: + return attribute.String(key, complexToString(typedVal)) + case string: + return attribute.String(key, typedVal) + case []bool: + return attribute.BoolSlice(key, typedVal) + case []int: + return attribute.IntSlice(key, typedVal) + case []int8: + return intSliceKV(key, typedVal) + case []int16: + return intSliceKV(key, typedVal) + case []int32: + return intSliceKV(key, typedVal) + case []int64: + return attribute.Int64Slice(key, typedVal) + case []uint: + return uintSliceKV(key, typedVal) + case []uint8: + return uintSliceKV(key, typedVal) + case []uint16: + return uintSliceKV(key, typedVal) + case []uint32: + return uintSliceKV(key, typedVal) + case []uintptr: + return uintSliceKV(key, typedVal) + case []uint64: + return uintSliceKV(key, typedVal) + case []float32: + floatSlice := make([]float64, len(typedVal)) + for i := range typedVal { + floatSlice[i] = float64(typedVal[i]) + } + return attribute.Float64Slice(key, floatSlice) + case []float64: + return attribute.Float64Slice(key, typedVal) + case []complex64: + return complexSliceKV(key, typedVal) + case []complex128: + return complexSliceKV(key, typedVal) + case []string: + return attribute.StringSlice(key, typedVal) + case fmt.Stringer: + return attribute.Stringer(key, typedVal) + default: + return attribute.String(key, fmt.Sprintf("unhandled attribute value: %+v", value)) + } +} + +func intSliceKV[N int8 | int16 | int32](key string, val []N) attribute.KeyValue { + intSlice := make([]int, len(val)) + for i := range val { + intSlice[i] = int(val[i]) + } + return attribute.IntSlice(key, intSlice) +} + +func uintKV(key string, val uint) attribute.KeyValue { + if val > uint(math.MaxInt) { + return attribute.String(key, strconv.FormatUint(uint64(val), 10)) + } + return attribute.Int(key, int(val)) +} + +func uintSliceKV[N uint | uint8 | uint16 | uint32 | uint64 | uintptr](key string, val []N) attribute.KeyValue { + strSlice := make([]string, len(val)) + for i := range val { + strSlice[i] = strconv.FormatUint(uint64(val[i]), 10) } - return bucketCounts, nil + return attribute.StringSlice(key, strSlice) +} + +func uint64KV(key string, val uint64) attribute.KeyValue { + const maxInt64 = ^uint64(0) >> 1 + if val > maxInt64 { + return attribute.String(key, strconv.FormatUint(val, 10)) + } + return attribute.Int64(key, int64(val)) +} + +func complexSliceKV[N complex64 | complex128](key string, val []N) attribute.KeyValue { + strSlice := make([]string, len(val)) + for i := range val { + strSlice[i] = complexToString(val[i]) + } + return attribute.StringSlice(key, strSlice) +} + +func complexToString[N complex64 | complex128](val N) string { + return strconv.FormatComplex(complex128(val), 'f', -1, 64) } // convertAttrs converts from OpenCensus attribute keys and values to an diff --git a/bridge/opencensus/internal/ocmetric/metric_test.go b/bridge/opencensus/internal/ocmetric/metric_test.go index a3258c6b1e7..bf851bc14ad 100644 --- a/bridge/opencensus/internal/ocmetric/metric_test.go +++ b/bridge/opencensus/internal/ocmetric/metric_test.go @@ -16,10 +16,15 @@ package internal // import "go.opentelemetry.io/otel/bridge/opencensus/opencensu import ( "errors" + "fmt" + "math" + "reflect" "testing" "time" + "github.com/stretchr/testify/assert" ocmetricdata "go.opencensus.io/metric/metricdata" + octrace "go.opencensus.io/trace" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/sdk/metric/metricdata" @@ -28,6 +33,7 @@ import ( func TestConvertMetrics(t *testing.T) { endTime1 := time.Now() + exemplarTime := endTime1.Add(-10 * time.Second) endTime2 := endTime1.Add(-time.Millisecond) startTime := endTime2.Add(-time.Minute) for _, tc := range []struct { @@ -73,9 +79,46 @@ func TestConvertMetrics(t *testing.T) { Bounds: []float64{1.0, 2.0, 3.0}, }, Buckets: []ocmetricdata.Bucket{ - {Count: 1}, - {Count: 2}, - {Count: 5}, + { + Count: 1, + Exemplar: &ocmetricdata.Exemplar{ + Value: 0.8, + Timestamp: exemplarTime, + Attachments: map[string]interface{}{ + ocmetricdata.AttachmentKeySpanContext: octrace.SpanContext{ + TraceID: octrace.TraceID([16]byte{1}), + SpanID: octrace.SpanID([8]byte{2}), + }, + "bool": true, + }, + }, + }, + { + Count: 2, + Exemplar: &ocmetricdata.Exemplar{ + Value: 1.5, + Timestamp: exemplarTime, + Attachments: map[string]interface{}{ + ocmetricdata.AttachmentKeySpanContext: octrace.SpanContext{ + TraceID: octrace.TraceID([16]byte{3}), + SpanID: octrace.SpanID([8]byte{4}), + }, + }, + }, + }, + { + Count: 5, + Exemplar: &ocmetricdata.Exemplar{ + Value: 2.6, + Timestamp: exemplarTime, + Attachments: map[string]interface{}{ + ocmetricdata.AttachmentKeySpanContext: octrace.SpanContext{ + TraceID: octrace.TraceID([16]byte{5}), + SpanID: octrace.SpanID([8]byte{6}), + }, + }, + }, + }, }, }), ocmetricdata.NewDistributionPoint(endTime2, &ocmetricdata.Distribution{ @@ -85,9 +128,45 @@ func TestConvertMetrics(t *testing.T) { Bounds: []float64{1.0, 2.0, 3.0}, }, Buckets: []ocmetricdata.Bucket{ - {Count: 1}, - {Count: 4}, - {Count: 5}, + { + Count: 1, + Exemplar: &ocmetricdata.Exemplar{ + Value: 0.9, + Timestamp: exemplarTime, + Attachments: map[string]interface{}{ + ocmetricdata.AttachmentKeySpanContext: octrace.SpanContext{ + TraceID: octrace.TraceID([16]byte{7}), + SpanID: octrace.SpanID([8]byte{8}), + }, + }, + }, + }, + { + Count: 4, + Exemplar: &ocmetricdata.Exemplar{ + Value: 1.1, + Timestamp: exemplarTime, + Attachments: map[string]interface{}{ + ocmetricdata.AttachmentKeySpanContext: octrace.SpanContext{ + TraceID: octrace.TraceID([16]byte{9}), + SpanID: octrace.SpanID([8]byte{10}), + }, + }, + }, + }, + { + Count: 5, + Exemplar: &ocmetricdata.Exemplar{ + Value: 2.7, + Timestamp: exemplarTime, + Attachments: map[string]interface{}{ + ocmetricdata.AttachmentKeySpanContext: octrace.SpanContext{ + TraceID: octrace.TraceID([16]byte{11}), + SpanID: octrace.SpanID([8]byte{12}), + }, + }, + }, + }, }, }), }, @@ -229,6 +308,29 @@ func TestConvertMetrics(t *testing.T) { Sum: 100.0, Bounds: []float64{1.0, 2.0, 3.0}, BucketCounts: []uint64{1, 2, 5}, + Exemplars: []metricdata.Exemplar[float64]{ + { + Time: exemplarTime, + Value: 0.8, + TraceID: []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + SpanID: []byte{2, 0, 0, 0, 0, 0, 0, 0}, + FilteredAttributes: []attribute.KeyValue{ + attribute.Bool("bool", true), + }, + }, + { + Time: exemplarTime, + Value: 1.5, + TraceID: []byte{3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + SpanID: []byte{4, 0, 0, 0, 0, 0, 0, 0}, + }, + { + Time: exemplarTime, + Value: 2.6, + TraceID: []byte{5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + SpanID: []byte{6, 0, 0, 0, 0, 0, 0, 0}, + }, + }, }, { Attributes: attribute.NewSet(attribute.KeyValue{ Key: attribute.Key("a"), @@ -243,6 +345,26 @@ func TestConvertMetrics(t *testing.T) { Sum: 110.0, Bounds: []float64{1.0, 2.0, 3.0}, BucketCounts: []uint64{1, 4, 5}, + Exemplars: []metricdata.Exemplar[float64]{ + { + Time: exemplarTime, + Value: 0.9, + TraceID: []byte{7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + SpanID: []byte{8, 0, 0, 0, 0, 0, 0, 0}, + }, + { + Time: exemplarTime, + Value: 1.1, + TraceID: []byte{9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + SpanID: []byte{10, 0, 0, 0, 0, 0, 0, 0}, + }, + { + Time: exemplarTime, + Value: 2.7, + TraceID: []byte{11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + SpanID: []byte{12, 0, 0, 0, 0, 0, 0, 0}, + }, + }, }, }, Temporality: metricdata.CumulativeTemporality, @@ -516,6 +638,46 @@ func TestConvertMetrics(t *testing.T) { }, expectedErr: errMismatchedValueTypes, }, + { + desc: "histogram with invalid span context exemplar", + input: []*ocmetricdata.Metric{ + { + Descriptor: ocmetricdata.Descriptor{ + Name: "foo.com/histogram-a", + Description: "a testing histogram", + Unit: ocmetricdata.UnitDimensionless, + Type: ocmetricdata.TypeCumulativeDistribution, + }, + TimeSeries: []*ocmetricdata.TimeSeries{ + { + Points: []ocmetricdata.Point{ + ocmetricdata.NewDistributionPoint(endTime1, &ocmetricdata.Distribution{ + Count: 8, + Sum: 100.0, + BucketOptions: &ocmetricdata.BucketOptions{ + Bounds: []float64{1.0, 2.0, 3.0}, + }, + Buckets: []ocmetricdata.Bucket{ + { + Count: 1, + Exemplar: &ocmetricdata.Exemplar{ + Value: 0.8, + Timestamp: exemplarTime, + Attachments: map[string]interface{}{ + ocmetricdata.AttachmentKeySpanContext: "notaspancontext", + }, + }, + }, + }, + }), + }, + StartTime: startTime, + }, + }, + }, + }, + expectedErr: errInvalidExemplarSpanContext, + }, { desc: "sum with non-sum datapoint type", input: []*ocmetricdata.Metric{ @@ -640,3 +802,176 @@ func TestConvertAttributes(t *testing.T) { }) } } + +type fakeStringer string + +func (f fakeStringer) String() string { + return string(f) +} + +func TestConvertKV(t *testing.T) { + key := "foo" + for _, tt := range []struct { + value any + expected attribute.Value + }{ + { + value: bool(true), + expected: attribute.BoolValue(true), + }, + { + value: []bool{true, false}, + expected: attribute.BoolSliceValue([]bool{true, false}), + }, + { + value: int(10), + expected: attribute.IntValue(10), + }, + { + value: []int{10, 20}, + expected: attribute.IntSliceValue([]int{10, 20}), + }, + { + value: int8(10), + expected: attribute.IntValue(10), + }, + { + value: []int8{10, 20}, + expected: attribute.IntSliceValue([]int{10, 20}), + }, + { + value: int16(10), + expected: attribute.IntValue(10), + }, + { + value: []int16{10, 20}, + expected: attribute.IntSliceValue([]int{10, 20}), + }, + { + value: int32(10), + expected: attribute.IntValue(10), + }, + { + value: []int32{10, 20}, + expected: attribute.IntSliceValue([]int{10, 20}), + }, + { + value: int64(10), + expected: attribute.Int64Value(10), + }, + { + value: []int64{10, 20}, + expected: attribute.Int64SliceValue([]int64{10, 20}), + }, + { + value: uint(10), + expected: attribute.IntValue(10), + }, + { + value: uint(math.MaxUint), + expected: attribute.StringValue(fmt.Sprintf("%v", uint(math.MaxUint))), + }, + { + value: []uint{10, 20}, + expected: attribute.StringSliceValue([]string{"10", "20"}), + }, + { + value: uint8(10), + expected: attribute.IntValue(10), + }, + { + value: []uint8{10, 20}, + expected: attribute.StringSliceValue([]string{"10", "20"}), + }, + { + value: uint16(10), + expected: attribute.IntValue(10), + }, + { + value: []uint16{10, 20}, + expected: attribute.StringSliceValue([]string{"10", "20"}), + }, + { + value: uint32(10), + expected: attribute.IntValue(10), + }, + { + value: []uint32{10, 20}, + expected: attribute.StringSliceValue([]string{"10", "20"}), + }, + { + value: uint64(10), + expected: attribute.Int64Value(10), + }, + { + value: uint64(math.MaxUint64), + expected: attribute.StringValue("18446744073709551615"), + }, + { + value: []uint64{10, 20}, + expected: attribute.StringSliceValue([]string{"10", "20"}), + }, + { + value: uintptr(10), + expected: attribute.Int64Value(10), + }, + { + value: []uintptr{10, 20}, + expected: attribute.StringSliceValue([]string{"10", "20"}), + }, + { + value: float32(10), + expected: attribute.Float64Value(10), + }, + { + value: []float32{10, 20}, + expected: attribute.Float64SliceValue([]float64{10, 20}), + }, + { + value: float64(10), + expected: attribute.Float64Value(10), + }, + { + value: []float64{10, 20}, + expected: attribute.Float64SliceValue([]float64{10, 20}), + }, + { + value: complex64(10), + expected: attribute.StringValue("(10+0i)"), + }, + { + value: []complex64{10, 20}, + expected: attribute.StringSliceValue([]string{"(10+0i)", "(20+0i)"}), + }, + { + value: complex128(10), + expected: attribute.StringValue("(10+0i)"), + }, + { + value: []complex128{10, 20}, + expected: attribute.StringSliceValue([]string{"(10+0i)", "(20+0i)"}), + }, + { + value: "string", + expected: attribute.StringValue("string"), + }, + { + value: []string{"string", "slice"}, + expected: attribute.StringSliceValue([]string{"string", "slice"}), + }, + { + value: fakeStringer("stringer"), + expected: attribute.StringValue("stringer"), + }, + { + value: metricdata.Histogram[float64]{}, + expected: attribute.StringValue("unhandled attribute value: {DataPoints:[] Temporality:undefinedTemporality}"), + }, + } { + t.Run(fmt.Sprintf("%v(%+v)", reflect.TypeOf(tt.value), tt.value), func(t *testing.T) { + got := convertKV(key, tt.value) + assert.Equal(t, key, string(got.Key)) + assert.Equal(t, tt.expected, got.Value) + }) + } +}