diff --git a/CHANGELOG.md b/CHANGELOG.md index 446b1643f..dc1c6091b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Support `BYTESLICE` attributes in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric`. (#8153) - Support `BYTESLICE` attributes in `go.opentelemetry.io/otel/exporters/zipkin`. (#8153) - Add `String` method for `Value` type in `go.opentelemetry.io/otel/attribute`. (#8142) +- Add `Slice` and `SliceValue` functions for new `SLICE` attribute type in `go.opentelemetry.io/otel/attribute`. (#8166) - Add `Error` field on `Record` type in `go.opentelemetry.io/otel/log/logtest`. (#8148) - Add experimental support for splitting metric data across multiple batches in `go.opentelemetry.io/otel/sdk/metric`. Set `OTEL_GO_X_METRIC_EXPORT_BATCH_SIZE=` to enable for all periodic readers. diff --git a/attribute/benchmark_test.go b/attribute/benchmark_test.go index ce018a5e6..5710e82b0 100644 --- a/attribute/benchmark_test.go +++ b/attribute/benchmark_test.go @@ -4,6 +4,7 @@ package attribute_test import ( + "math" "testing" "go.opentelemetry.io/otel/attribute" @@ -23,6 +24,7 @@ var ( outFloat64Slice []float64 outStr string outStrSlice []string + outValueSlice []attribute.Value ) func benchmarkEmit(kv attribute.KeyValue) func(*testing.B) { @@ -341,6 +343,58 @@ func BenchmarkStringSlice(b *testing.B) { } } +func BenchmarkSlice(b *testing.B) { + for _, bench := range []struct { + name string + v []attribute.Value + }{ + { + name: "Len3", + v: []attribute.Value{ + attribute.BoolValue(true), + attribute.IntValue(42), + attribute.StringValue("test"), + }, + }, + { + name: "Len5Nested", + v: []attribute.Value{ + attribute.StringValue("quote\""), + attribute.Float64Value(math.Inf(1)), + attribute.ByteSliceValue([]byte("bin")), + attribute.SliceValue(attribute.StringValue("nested"), attribute.Value{}), + attribute.BoolValue(false), + }, + }, + } { + b.Run(bench.name, func(b *testing.B) { + k, v := "slice", bench.v + kv := attribute.Slice(k, v...) + + b.Run("Value", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + outV = attribute.SliceValue(v...) + } + }) + b.Run("KeyValue", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + outKV = attribute.Slice(k, v...) + } + }) + b.Run("AsSlice", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + outValueSlice = kv.Value.AsSlice() + } + }) + b.Run("String", benchmarkString(kv)) + b.Run("Emit", benchmarkEmit(kv)) + }) + } +} + func BenchmarkByteSlice(b *testing.B) { k, v := "bytes", []byte("forty-two") kv := attribute.ByteSlice(k, v) diff --git a/attribute/hash.go b/attribute/hash.go index 4409a4c11..92f39ffe7 100644 --- a/attribute/hash.go +++ b/attribute/hash.go @@ -28,6 +28,7 @@ const ( float64SliceID uint64 = 7308324551835016539 // "[]double" (little endian) stringSliceID uint64 = 7453010373645655387 // "[]string" (little endian) byteSliceID uint64 = 6874028470941080415 // "_[]byte_" (little endian) + sliceID uint64 = 7883494272577650031 // "__slice_" (little endian) emptyID uint64 = 7305809155345288421 // "__empty_" (little endian) ) @@ -43,56 +44,87 @@ func hashKVs(kvs []KeyValue) uint64 { // hashKV returns the xxHash64 hash of kv with h as the base. func hashKV(h xxhash.Hash, kv KeyValue) xxhash.Hash { h = h.String(string(kv.Key)) + return hashValue(h, kv.Value) +} - switch kv.Value.Type() { +func hashValue(h xxhash.Hash, v Value) xxhash.Hash { + switch v.Type() { case BOOL: h = h.Uint64(boolID) - h = h.Uint64(kv.Value.numeric) + h = h.Uint64(v.numeric) case INT64: h = h.Uint64(int64ID) - h = h.Uint64(kv.Value.numeric) + h = h.Uint64(v.numeric) case FLOAT64: h = h.Uint64(float64ID) // Assumes numeric stored with math.Float64bits. - h = h.Uint64(kv.Value.numeric) + h = h.Uint64(v.numeric) case STRING: h = h.Uint64(stringID) - h = h.String(kv.Value.stringly) + h = h.String(v.stringly) case BOOLSLICE: h = h.Uint64(boolSliceID) - rv := reflect.ValueOf(kv.Value.slice) + rv := reflect.ValueOf(v.slice) for i := 0; i < rv.Len(); i++ { h = h.Bool(rv.Index(i).Bool()) } case INT64SLICE: h = h.Uint64(int64SliceID) - rv := reflect.ValueOf(kv.Value.slice) + rv := reflect.ValueOf(v.slice) for i := 0; i < rv.Len(); i++ { h = h.Int64(rv.Index(i).Int()) } case FLOAT64SLICE: h = h.Uint64(float64SliceID) - rv := reflect.ValueOf(kv.Value.slice) + rv := reflect.ValueOf(v.slice) for i := 0; i < rv.Len(); i++ { h = h.Float64(rv.Index(i).Float()) } case STRINGSLICE: h = h.Uint64(stringSliceID) - rv := reflect.ValueOf(kv.Value.slice) + rv := reflect.ValueOf(v.slice) for i := 0; i < rv.Len(); i++ { h = h.String(rv.Index(i).String()) } case BYTESLICE: h = h.Uint64(byteSliceID) - h = h.String(kv.Value.stringly) + h = h.String(v.stringly) + case SLICE: + h = h.Uint64(sliceID) + switch vals := v.slice.(type) { + case [0]Value: + // No values to hash, but the type identifier is still hashed above. + case [1]Value: + h = hashValueSlice(h, vals[:]) + case [2]Value: + h = hashValueSlice(h, vals[:]) + case [3]Value: + h = hashValueSlice(h, vals[:]) + case [4]Value: + h = hashValueSlice(h, vals[:]) + case [5]Value: + h = hashValueSlice(h, vals[:]) + default: + rv := reflect.ValueOf(v.slice) + for i := 0; i < rv.Len(); i++ { + h = hashValue(h, rv.Index(i).Interface().(Value)) + } + } case EMPTY: h = h.Uint64(emptyID) default: // Logging is an alternative, but using the internal logger here // causes an import cycle so it is not done. - v := kv.Value.AsInterface() - msg := fmt.Sprintf("unknown value type: %[1]v (%[1]T)", v) + val := v.AsInterface() + msg := fmt.Sprintf("unknown value type: %[1]v (%[1]T)", val) panic(msg) } return h } + +func hashValueSlice(h xxhash.Hash, vals []Value) xxhash.Hash { + for _, v := range vals { + h = hashValue(h, v) + } + return h +} diff --git a/attribute/hash_test.go b/attribute/hash_test.go index a62edf605..200a827f8 100644 --- a/attribute/hash_test.go +++ b/attribute/hash_test.go @@ -11,6 +11,8 @@ import ( "slices" "strings" "testing" + + "go.opentelemetry.io/otel/attribute/internal/xxhash" ) // keyVals is all the KeyValue generators that are used for testing. This is @@ -38,6 +40,43 @@ var keyVals = []func(string) KeyValue{ func(k string) KeyValue { return StringSlice(k, []string{"[]i1"}) }, func(k string) KeyValue { return ByteSlice(k, []byte("foo")) }, func(k string) KeyValue { return ByteSlice(k, []byte("[]i1")) }, + func(k string) KeyValue { return Slice(k) }, + func(k string) KeyValue { return Slice(k, BoolValue(true)) }, + func(k string) KeyValue { return Slice(k, BoolValue(true), IntValue(42)) }, + func(k string) KeyValue { + return Slice(k, + StringValue("triad"), + IntValue(3), + BoolValue(false), + ) + }, + func(k string) KeyValue { + return Slice(k, + StringValue("quad"), + IntValue(4), + BoolValue(false), + Float64Value(4.25), + ) + }, + func(k string) KeyValue { + return Slice(k, + StringValue("penta"), + IntValue(5), + BoolValue(true), + Float64Value(5.5), + ByteSliceValue([]byte("five")), + ) + }, + func(k string) KeyValue { + return Slice(k, + StringValue("nested"), + SliceValue(Float64Value(math.Inf(1)), ByteSliceValue([]byte("bin"))), + BoolValue(true), + IntValue(6), + StringValue("tail"), + StringSliceValue([]string{"fallback"}), + ) + }, func(k string) KeyValue { return KeyValue{Key: Key(k)} }, // Empty value. } @@ -129,6 +168,53 @@ func BenchmarkHashKVs(b *testing.B) { } } +func BenchmarkHashValueSlice(b *testing.B) { + benches := []struct { + name string + v Value + }{ + { + name: "Len2", + v: SliceValue( + BoolValue(true), + StringValue("two"), + ), + }, + { + name: "Len5", + v: SliceValue( + BoolValue(true), + IntValue(2), + StringValue("three"), + Float64Value(4.5), + ByteSliceValue([]byte("five")), + ), + }, + { + name: "Len8Nested", + v: SliceValue( + BoolValue(true), + IntValue(2), + StringValue("three"), + Float64Value(4.5), + ByteSliceValue([]byte("five")), + SliceValue(StringValue("nested"), Int64Value(6)), + BoolSliceValue([]bool{true, false, true}), + StringSliceValue([]string{"seven", "eight"}), + ), + }, + } + + for _, bench := range benches { + b.Run(bench.name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + hashValue(xxhash.New(), bench.v).Sum64() + } + }) + } +} + func FuzzHashKVs(f *testing.F) { // Add seed inputs to ensure coverage of edge cases. f.Add("", "", "", "", "", "", 0, int64(0), 0.0, false, uint8(0)) @@ -167,9 +253,9 @@ func FuzzHashKVs(f *testing.F) { kvs = append(kvs, Bool(k5, b)) } - // Add slice types based on sliceType parameter + // Add slice types based on sliceType parameter. if numAttrs > 5 { - switch sliceType % 5 { + switch sliceType % 6 { case 0: // Test BoolSlice with variable length. bools := make([]bool, len(s)%5) // 0-4 elements @@ -214,6 +300,21 @@ func FuzzHashKVs(f *testing.F) { bytes[i] = byte(i + len(k1)) } kvs = append(kvs, ByteSlice("bytes", bytes)) + case 5: + values := make([]Value, len(s)%4) // 0-3 elements + for i := range values { + switch i % 4 { + case 0: + values[i] = BoolValue((i+len(k1))%2 == 0) + case 1: + values[i] = IntValue(i + len(k2)) + case 2: + values[i] = StringValue(fmt.Sprintf("item_%d", i)) + case 3: + values[i] = SliceValue(Float64Value(fVal), ByteSliceValue([]byte("bin"))) + } + } + kvs = append(kvs, Slice("slice", values...)) } } @@ -295,6 +396,22 @@ func FuzzHashKVs(f *testing.F) { if !math.IsNaN(val) && !math.IsInf(val, 0) { modifiedKvs[0] = Float64(string(modifiedKvs[0].Key), val+1.0) } + case SLICE: + origSlice := modifiedKvs[0].Value.AsSlice() + if len(origSlice) > 0 { + newSlice := slices.Clone(origSlice) + switch newSlice[0].Type() { + case INT64: + newSlice[0] = Int64Value(newSlice[0].AsInt64() + 1) + case BOOL: + newSlice[0] = BoolValue(!newSlice[0].AsBool()) + case STRING: + newSlice[0] = StringValue(newSlice[0].AsString() + "_mod") + default: + newSlice[0] = StringValue("modified") + } + modifiedKvs[0] = Slice(string(modifiedKvs[0].Key), newSlice...) + } case EMPTY: modifiedKvs[0] = String(string(modifiedKvs[0].Key), "not_empty") } diff --git a/attribute/key.go b/attribute/key.go index cc4bcb02d..cdc7089e8 100644 --- a/attribute/key.go +++ b/attribute/key.go @@ -128,6 +128,17 @@ func (k Key) ByteSlice(v []byte) KeyValue { } } +// Slice creates a KeyValue instance with a SLICE Value. +// +// If creating both a key and value at the same time, use the provided +// convenience function instead -- Slice(name, values...). +func (k Key) Slice(v ...Value) KeyValue { + return KeyValue{ + Key: k, + Value: SliceValue(v...), + } +} + // Defined reports whether the key is not empty. func (k Key) Defined() bool { return len(k) != 0 diff --git a/attribute/key_test.go b/attribute/key_test.go index 95c79cc49..88ab25ff4 100644 --- a/attribute/key_test.go +++ b/attribute/key_test.go @@ -5,6 +5,7 @@ package attribute_test import ( "encoding/json" + "math" "testing" "github.com/stretchr/testify/require" @@ -113,6 +114,16 @@ func TestEmit(t *testing.T) { v: attribute.ByteSliceValue([]byte("foo")), want: "Zm9v", }, + { + name: `test Key.Emit() can emit a string representing self.SLICE`, + v: attribute.SliceValue( + attribute.BoolValue(true), + attribute.StringValue("foo\"bar"), + attribute.Float64Value(math.Inf(1)), + attribute.ByteSliceValue([]byte("bin")), + ), + want: `[true,"foo\"bar","Infinity","Ymlu"]`, + }, { name: `test Key.Emit() can emit a string representing self.EMPTY`, v: attribute.Value{}, @@ -128,3 +139,16 @@ func TestEmit(t *testing.T) { }) } } + +func TestString(t *testing.T) { + v := attribute.SliceValue( + attribute.StringValue("foo\nbar"), + attribute.Float64Value(math.NaN()), + attribute.SliceValue( + attribute.ByteSliceValue([]byte("bin")), + attribute.Value{}, + ), + ) + + require.Equal(t, `["foo\nbar","NaN",["Ymlu",null]]`, v.String()) +} diff --git a/attribute/kv.go b/attribute/kv.go index 736c135cb..eeb76a134 100644 --- a/attribute/kv.go +++ b/attribute/kv.go @@ -73,6 +73,11 @@ func ByteSlice(k string, v []byte) KeyValue { return Key(k).ByteSlice(v) } +// Slice creates a KeyValue with a SLICE Value type. +func Slice(k string, v ...Value) KeyValue { + return Key(k).Slice(v...) +} + // Stringer creates a new key-value pair with a passed name and a string // value generated by the passed Stringer interface. func Stringer(k string, v fmt.Stringer) KeyValue { diff --git a/attribute/kv_test.go b/attribute/kv_test.go index fb61676ce..993e946a2 100644 --- a/attribute/kv_test.go +++ b/attribute/kv_test.go @@ -66,6 +66,14 @@ func TestKeyValueConstructors(t *testing.T) { Value: attribute.ByteSliceValue([]byte{123}), }, }, + { + name: "Slice", + actual: attribute.Slice("k1", attribute.BoolValue(true), attribute.IntValue(42)), + expected: attribute.KeyValue{ + Key: "k1", + Value: attribute.SliceValue(attribute.BoolValue(true), attribute.IntValue(42)), + }, + }, } for _, test := range tt { @@ -127,6 +135,11 @@ func TestKeyValueValid(t *testing.T) { valid: true, kv: attribute.ByteSlice("bytes", []byte{}), }, + { + desc: "non-empty key with SLICE type Value should be valid", + valid: true, + kv: attribute.Slice("slice", attribute.StringValue("value")), + }, } for _, test := range tests { @@ -173,6 +186,10 @@ func TestIncorrectCast(t *testing.T) { name: "Empty", val: attribute.Value{}, }, + { + name: "Slice", + val: attribute.SliceValue(attribute.StringValue("value")), + }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { @@ -184,6 +201,7 @@ func TestIncorrectCast(t *testing.T) { tt.val.AsInt64() tt.val.AsInt64Slice() tt.val.AsInterface() + tt.val.AsSlice() tt.val.AsString() tt.val.AsStringSlice() tt.val.AsByteSlice() diff --git a/attribute/type_string.go b/attribute/type_string.go index bd7816331..dbc01d324 100644 --- a/attribute/type_string.go +++ b/attribute/type_string.go @@ -18,11 +18,12 @@ func _() { _ = x[FLOAT64SLICE-7] _ = x[STRINGSLICE-8] _ = x[BYTESLICE-9] + _ = x[SLICE-10] } -const _Type_name = "EMPTYBOOLINT64FLOAT64STRINGBOOLSLICEINT64SLICEFLOAT64SLICESTRINGSLICEBYTESLICE" +const _Type_name = "EMPTYBOOLINT64FLOAT64STRINGBOOLSLICEINT64SLICEFLOAT64SLICESTRINGSLICEBYTESLICESLICE" -var _Type_index = [...]uint8{0, 5, 9, 14, 21, 27, 36, 46, 58, 69, 78} +var _Type_index = [...]uint8{0, 5, 9, 14, 21, 27, 36, 46, 58, 69, 78, 83} func (i Type) String() string { idx := int(i) - 0 diff --git a/attribute/value.go b/attribute/value.go index 146f266ef..0529fefae 100644 --- a/attribute/value.go +++ b/attribute/value.go @@ -52,6 +52,8 @@ const ( STRINGSLICE // BYTESLICE is a slice of bytes Type Value. BYTESLICE + // SLICE is a slice of Value Type values. + SLICE // INVALID is used for a Value with no value set. // // Deprecated: Use EMPTY instead as an empty value is a valid value. @@ -149,6 +151,11 @@ func ByteSliceValue(v []byte) Value { } } +// SliceValue creates a SLICE Value. +func SliceValue(v ...Value) Value { + return Value{vtype: SLICE, slice: sliceValue(v)} +} + // Type returns a type of the Value. func (v Value) Type() Type { return v.vtype @@ -230,6 +237,46 @@ func (v Value) asStringSlice() []string { return attribute.AsSlice[string](v.slice) } +// AsSlice returns the []Value value. Make sure that the Value's type is +// SLICE. +func (v Value) AsSlice() []Value { + if v.vtype != SLICE { + return nil + } + return v.asSlice() +} + +func (v Value) asSlice() []Value { + switch vals := v.slice.(type) { + case [0]Value: + return []Value{} + case [1]Value: + return []Value{vals[0]} + case [2]Value: + return []Value{vals[0], vals[1]} + case [3]Value: + return []Value{vals[0], vals[1], vals[2]} + case [4]Value: + return []Value{vals[0], vals[1], vals[2], vals[3]} + case [5]Value: + return []Value{vals[0], vals[1], vals[2], vals[3], vals[4]} + default: + return asValueSliceReflect(v.slice) + } +} + +func asValueSliceReflect(v any) []Value { + rv := reflect.ValueOf(v) + if !rv.IsValid() || rv.Kind() != reflect.Array || rv.Type().Elem() != reflect.TypeFor[Value]() { + return nil + } + cpy := make([]Value, rv.Len()) + if len(cpy) > 0 { + _ = reflect.Copy(reflect.ValueOf(cpy), rv) + } + return cpy +} + // AsByteSlice returns the bytes value. Make sure that the Value's type // is BYTESLICE. func (v Value) AsByteSlice() []byte { @@ -266,6 +313,8 @@ func (v Value) AsInterface() any { return v.asStringSlice() case BYTESLICE: return v.asByteSlice() + case SLICE: + return v.asSlice() case EMPTY: return nil } @@ -279,8 +328,9 @@ func (v Value) AsInterface() any { // JSON literals, floating-point values use JSON numbers except that NaN and // ±Inf are rendered as NaN, Infinity, and -Infinity, byte slices are // base64-encoded, empty values are the empty string, and slices are encoded as -// JSON arrays. Floating-point special values inside arrays are encoded as JSON -// strings. +// JSON arrays. String, byte, and special floating-point values inside arrays +// are encoded as JSON strings, and empty values inside arrays are encoded as +// null. // // [OpenTelemetry AnyValue representation for non-OTLP protocols]: https://opentelemetry.io/docs/specs/otel/common/#anyvalue-representation-for-non-otlp-protocols func (v Value) String() string { @@ -302,7 +352,9 @@ func (v Value) String() string { case STRINGSLICE: return formatStringSliceValue(v.slice) case BYTESLICE: - return base64.StdEncoding.EncodeToString(v.asByteSlice()) + return formatByteSlice(v.stringly) + case SLICE: + return formatValueSliceValue(v.slice) case EMPTY: return "" default: @@ -344,7 +396,9 @@ func (v Value) Emit() string { case STRING: return v.stringly case BYTESLICE: - return base64.StdEncoding.EncodeToString(v.asByteSlice()) + return formatByteSlice(v.stringly) + case SLICE: + return formatValueSliceValue(v.slice) case EMPTY: return "" default: @@ -360,6 +414,31 @@ const ( commaLen = len(",") ) +func sliceValue(v []Value) any { + switch len(v) { + case 0: + return [0]Value{} + case 1: + return [1]Value{v[0]} + case 2: + return [2]Value{v[0], v[1]} + case 3: + return [3]Value{v[0], v[1], v[2]} + case 4: + return [4]Value{v[0], v[1], v[2], v[3]} + case 5: + return [5]Value{v[0], v[1], v[2], v[3], v[4]} + default: + return sliceValueReflect(v) + } +} + +func sliceValueReflect(v []Value) any { + cp := reflect.New(reflect.ArrayOf(len(v), reflect.TypeFor[Value]())).Elem() + reflect.Copy(cp, reflect.ValueOf(v)) + return cp.Interface() +} + func formatBoolSliceValue(v any) string { switch vals := v.(type) { case [0]bool: @@ -377,40 +456,61 @@ func formatBoolSliceValue(v any) string { func formatBoolSlice(vals []bool) string { var b strings.Builder - b.Grow(jsonArrayBracketsLen + len(vals)*(boolArrayElemMaxLen+commaLen)) - _ = b.WriteByte('[') - for i, val := range vals { - if i > 0 { - _ = b.WriteByte(',') - } - if val { - _, _ = b.WriteString("true") - } else { - _, _ = b.WriteString("false") - } - } - _ = b.WriteByte(']') + appendBoolSlice(&b, vals) return b.String() } func formatBoolSliceReflect(v any) string { - rv := reflect.ValueOf(v) - var b strings.Builder - b.Grow(jsonArrayBracketsLen + rv.Len()*(boolArrayElemMaxLen+commaLen)) - _ = b.WriteByte('[') - for i := 0; i < rv.Len(); i++ { + appendBoolSliceReflect(&b, reflect.ValueOf(v)) + return b.String() +} + +func appendBoolSliceValue(dst *strings.Builder, v any) { + switch vals := v.(type) { + case [0]bool: + _, _ = dst.WriteString("[]") + case [1]bool: + appendBoolSlice(dst, vals[:]) + case [2]bool: + appendBoolSlice(dst, vals[:]) + case [3]bool: + appendBoolSlice(dst, vals[:]) + default: + appendBoolSliceReflect(dst, reflect.ValueOf(v)) + } +} + +func appendBoolSlice(dst *strings.Builder, vals []bool) { + dst.Grow(jsonArrayBracketsLen + len(vals)*(boolArrayElemMaxLen+commaLen)) + _ = dst.WriteByte('[') + for i, val := range vals { if i > 0 { - _ = b.WriteByte(',') + _ = dst.WriteByte(',') } - if rv.Index(i).Bool() { - _, _ = b.WriteString("true") + if val { + _, _ = dst.WriteString("true") } else { - _, _ = b.WriteString("false") + _, _ = dst.WriteString("false") } } - _ = b.WriteByte(']') - return b.String() + _ = dst.WriteByte(']') +} + +func appendBoolSliceReflect(dst *strings.Builder, rv reflect.Value) { + dst.Grow(jsonArrayBracketsLen + rv.Len()*(boolArrayElemMaxLen+commaLen)) + _ = dst.WriteByte('[') + for i := 0; i < rv.Len(); i++ { + if i > 0 { + _ = dst.WriteByte(',') + } + if rv.Index(i).Bool() { + _, _ = dst.WriteString("true") + } else { + _, _ = dst.WriteString("false") + } + } + _ = dst.WriteByte(']') } func formatInt64SliceValue(v any) string { @@ -430,40 +530,61 @@ func formatInt64SliceValue(v any) string { func formatInt64Slice(vals []int64) string { var b strings.Builder - b.Grow(jsonArrayBracketsLen + len(vals)*(int64ArrayElemMaxLen+commaLen)) - _ = b.WriteByte('[') - - var buf [int64ArrayElemMaxLen]byte - for i, val := range vals { - if i > 0 { - _ = b.WriteByte(',') - } - out := strconv.AppendInt(buf[:0], val, 10) - _, _ = b.Write(out) - } - - _ = b.WriteByte(']') + appendInt64Slice(&b, vals) return b.String() } func formatInt64SliceReflect(v any) string { - rv := reflect.ValueOf(v) - var b strings.Builder - b.Grow(jsonArrayBracketsLen + rv.Len()*(int64ArrayElemMaxLen+commaLen)) - _ = b.WriteByte('[') + appendInt64SliceReflect(&b, reflect.ValueOf(v)) + return b.String() +} - var scratch [20]byte - for i := 0; i < rv.Len(); i++ { +func appendInt64SliceValue(dst *strings.Builder, v any) { + switch vals := v.(type) { + case [0]int64: + _, _ = dst.WriteString("[]") + case [1]int64: + appendInt64Slice(dst, vals[:]) + case [2]int64: + appendInt64Slice(dst, vals[:]) + case [3]int64: + appendInt64Slice(dst, vals[:]) + default: + appendInt64SliceReflect(dst, reflect.ValueOf(v)) + } +} + +func appendInt64Slice(dst *strings.Builder, vals []int64) { + dst.Grow(jsonArrayBracketsLen + len(vals)*(int64ArrayElemMaxLen+commaLen)) + _ = dst.WriteByte('[') + + var buf [int64ArrayElemMaxLen]byte + for i, val := range vals { if i > 0 { - _ = b.WriteByte(',') + _ = dst.WriteByte(',') } - out := strconv.AppendInt(scratch[:0], rv.Index(i).Int(), 10) - _, _ = b.Write(out) + out := strconv.AppendInt(buf[:0], val, 10) + _, _ = dst.Write(out) } - _ = b.WriteByte(']') - return b.String() + _ = dst.WriteByte(']') +} + +func appendInt64SliceReflect(dst *strings.Builder, rv reflect.Value) { + dst.Grow(jsonArrayBracketsLen + rv.Len()*(int64ArrayElemMaxLen+commaLen)) + _ = dst.WriteByte('[') + + var scratch [int64ArrayElemMaxLen]byte + for i := 0; i < rv.Len(); i++ { + if i > 0 { + _ = dst.WriteByte(',') + } + out := strconv.AppendInt(scratch[:0], rv.Index(i).Int(), 10) + _, _ = dst.Write(out) + } + + _ = dst.WriteByte(']') } func formatFloat64(v float64) string { @@ -496,60 +617,81 @@ func formatFloat64SliceValue(v any) string { func formatFloat64Slice(vals []float64) string { var b strings.Builder - b.Grow(jsonArrayBracketsLen + len(vals)*(float64ArrayElemMaxLen+commaLen)) - _ = b.WriteByte('[') - - var buf [float64ArrayElemMaxLen]byte - for i, val := range vals { - if i > 0 { - _ = b.WriteByte(',') - } - - switch { - case math.IsNaN(val): - _, _ = b.WriteString(`"NaN"`) - case math.IsInf(val, 1): - _, _ = b.WriteString(`"Infinity"`) - case math.IsInf(val, -1): - _, _ = b.WriteString(`"-Infinity"`) - default: - out := strconv.AppendFloat(buf[:0], val, 'g', -1, 64) - _, _ = b.Write(out) - } - } - - _ = b.WriteByte(']') + appendFloat64Slice(&b, vals) return b.String() } func formatFloat64SliceReflect(v any) string { - rv := reflect.ValueOf(v) - var b strings.Builder - b.Grow(jsonArrayBracketsLen + rv.Len()*(float64ArrayElemMaxLen+commaLen)) - _ = b.WriteByte('[') + appendFloat64SliceReflect(&b, reflect.ValueOf(v)) + return b.String() +} - var scratch [24]byte +func appendFloat64SliceValue(dst *strings.Builder, v any) { + switch vals := v.(type) { + case [0]float64: + _, _ = dst.WriteString("[]") + case [1]float64: + appendFloat64Slice(dst, vals[:]) + case [2]float64: + appendFloat64Slice(dst, vals[:]) + case [3]float64: + appendFloat64Slice(dst, vals[:]) + default: + appendFloat64SliceReflect(dst, reflect.ValueOf(v)) + } +} + +func appendFloat64Slice(dst *strings.Builder, vals []float64) { + dst.Grow(jsonArrayBracketsLen + len(vals)*(float64ArrayElemMaxLen+commaLen)) + _ = dst.WriteByte('[') + + var buf [float64ArrayElemMaxLen]byte + for i, val := range vals { + if i > 0 { + _ = dst.WriteByte(',') + } + + switch { + case math.IsNaN(val): + _, _ = dst.WriteString(`"NaN"`) + case math.IsInf(val, 1): + _, _ = dst.WriteString(`"Infinity"`) + case math.IsInf(val, -1): + _, _ = dst.WriteString(`"-Infinity"`) + default: + out := strconv.AppendFloat(buf[:0], val, 'g', -1, 64) + _, _ = dst.Write(out) + } + } + + _ = dst.WriteByte(']') +} + +func appendFloat64SliceReflect(dst *strings.Builder, rv reflect.Value) { + dst.Grow(jsonArrayBracketsLen + rv.Len()*(float64ArrayElemMaxLen+commaLen)) + _ = dst.WriteByte('[') + + var scratch [float64ArrayElemMaxLen]byte for i := 0; i < rv.Len(); i++ { if i > 0 { - _ = b.WriteByte(',') + _ = dst.WriteByte(',') } val := rv.Index(i).Float() switch { case math.IsNaN(val): - _, _ = b.WriteString(`"NaN"`) + _, _ = dst.WriteString(`"NaN"`) case math.IsInf(val, 1): - _, _ = b.WriteString(`"Infinity"`) + _, _ = dst.WriteString(`"Infinity"`) case math.IsInf(val, -1): - _, _ = b.WriteString(`"-Infinity"`) + _, _ = dst.WriteString(`"-Infinity"`) default: out := strconv.AppendFloat(scratch[:0], val, 'g', -1, 64) - _, _ = b.Write(out) + _, _ = dst.Write(out) } } - _ = b.WriteByte(']') - return b.String() + _ = dst.WriteByte(']') } func formatStringSliceValue(v any) string { @@ -568,45 +710,197 @@ func formatStringSliceValue(v any) string { } func formatStringSlice(vals []string) string { + var b strings.Builder + appendStringSlice(&b, vals) + return b.String() +} + +func formatStringSliceReflect(v any) string { + var b strings.Builder + appendStringSliceReflect(&b, reflect.ValueOf(v)) + return b.String() +} + +func appendStringSliceValue(dst *strings.Builder, v any) { + switch vals := v.(type) { + case [0]string: + _, _ = dst.WriteString("[]") + case [1]string: + appendStringSlice(dst, vals[:]) + case [2]string: + appendStringSlice(dst, vals[:]) + case [3]string: + appendStringSlice(dst, vals[:]) + default: + appendStringSliceReflect(dst, reflect.ValueOf(v)) + } +} + +func appendStringSlice(dst *strings.Builder, vals []string) { size := jsonArrayBracketsLen for _, val := range vals { size += len(val) + commaLen + 2 // Account for JSON string quotes and comma. } - var b strings.Builder - b.Grow(size) - _ = b.WriteByte('[') + dst.Grow(size) + _ = dst.WriteByte('[') for i, val := range vals { if i > 0 { - _ = b.WriteByte(',') + _ = dst.WriteByte(',') } - appendJSONString(&b, val) + appendJSONString(dst, val) } - _ = b.WriteByte(']') - return b.String() + _ = dst.WriteByte(']') } -func formatStringSliceReflect(v any) string { - rv := reflect.ValueOf(v) - +func appendStringSliceReflect(dst *strings.Builder, rv reflect.Value) { size := jsonArrayBracketsLen for i := 0; i < rv.Len(); i++ { size += len(rv.Index(i).String()) + commaLen + 2 // Account for JSON string quotes and comma. } - var b strings.Builder - b.Grow(size) - _ = b.WriteByte('[') + dst.Grow(size) + _ = dst.WriteByte('[') for i := 0; i < rv.Len(); i++ { if i > 0 { - _ = b.WriteByte(',') + _ = dst.WriteByte(',') } - appendJSONString(&b, rv.Index(i).String()) + appendJSONString(dst, rv.Index(i).String()) } - _ = b.WriteByte(']') + _ = dst.WriteByte(']') +} + +func formatByteSlice(v string) string { + var b strings.Builder + appendBase64(&b, v) return b.String() } +func formatValueSliceValue(v any) string { + switch vals := v.(type) { + case [0]Value: + return "[]" + case [1]Value: + return formatValueSlice(vals[:]) + case [2]Value: + return formatValueSlice(vals[:]) + case [3]Value: + return formatValueSlice(vals[:]) + case [4]Value: + return formatValueSlice(vals[:]) + case [5]Value: + return formatValueSlice(vals[:]) + default: + return formatValueSliceReflect(v) + } +} + +func formatValueSlice(vals []Value) string { + var b strings.Builder + appendValueSlice(&b, vals) + return b.String() +} + +func formatValueSliceReflect(v any) string { + var b strings.Builder + appendValueSliceReflect(&b, reflect.ValueOf(v)) + return b.String() +} + +func appendValueSliceValue(dst *strings.Builder, v any) { + switch vals := v.(type) { + case [0]Value: + _, _ = dst.WriteString("[]") + case [1]Value: + appendValueSlice(dst, vals[:]) + case [2]Value: + appendValueSlice(dst, vals[:]) + case [3]Value: + appendValueSlice(dst, vals[:]) + case [4]Value: + appendValueSlice(dst, vals[:]) + case [5]Value: + appendValueSlice(dst, vals[:]) + default: + appendValueSliceReflect(dst, reflect.ValueOf(v)) + } +} + +func appendValueSlice(dst *strings.Builder, vals []Value) { + // Estimate 10 bytes per value for small values and commas. + dst.Grow(jsonArrayBracketsLen + len(vals)*commaLen + len(vals)*10) + _ = dst.WriteByte('[') + for i, val := range vals { + if i > 0 { + _ = dst.WriteByte(',') + } + appendJSONValue(dst, val) + } + _ = dst.WriteByte(']') +} + +func appendValueSliceReflect(dst *strings.Builder, rv reflect.Value) { + // Estimate 10 bytes per value for small values and commas. + dst.Grow(jsonArrayBracketsLen + rv.Len()*commaLen + rv.Len()*10) + _ = dst.WriteByte('[') + for i := 0; i < rv.Len(); i++ { + if i > 0 { + _ = dst.WriteByte(',') + } + appendJSONValue(dst, rv.Index(i).Interface().(Value)) + } + _ = dst.WriteByte(']') +} + +func appendJSONValue(dst *strings.Builder, v Value) { + switch v.Type() { + case BOOL: + if v.AsBool() { + _, _ = dst.WriteString("true") + } else { + _, _ = dst.WriteString("false") + } + case BOOLSLICE: + appendBoolSliceValue(dst, v.slice) + case INT64: + var buf [int64ArrayElemMaxLen]byte + out := strconv.AppendInt(buf[:0], v.AsInt64(), 10) + _, _ = dst.Write(out) + case INT64SLICE: + appendInt64SliceValue(dst, v.slice) + case FLOAT64: + val := v.AsFloat64() + switch { + case math.IsNaN(val): + appendJSONString(dst, "NaN") + case math.IsInf(val, 1): + appendJSONString(dst, "Infinity") + case math.IsInf(val, -1): + appendJSONString(dst, "-Infinity") + default: + var buf [float64ArrayElemMaxLen]byte + out := strconv.AppendFloat(buf[:0], val, 'g', -1, 64) + _, _ = dst.Write(out) + } + case FLOAT64SLICE: + appendFloat64SliceValue(dst, v.slice) + case STRING: + appendJSONString(dst, v.stringly) + case STRINGSLICE: + appendStringSliceValue(dst, v.slice) + case BYTESLICE: + _ = dst.WriteByte('"') + appendBase64(dst, v.stringly) + _ = dst.WriteByte('"') + case SLICE: + appendValueSliceValue(dst, v.slice) + case EMPTY: + _, _ = dst.WriteString("null") + default: + appendJSONString(dst, "unknown") + } +} + // appendJSONString appends s to dst as a JSON string literal. // // This is adapted from the Go standard library's encoding/json @@ -692,6 +986,42 @@ func appendJSONString(dst *strings.Builder, s string) { _ = dst.WriteByte('"') } +// This is adapted from the Go standard library's encoding/base64 +// [Encoding.Encode implementation]. It keeps the same encoding behavior we need +// here, but writes directly into a strings.Builder. We inline this instead of using +// encoding/base64 to avoid allocations. +// +// [Encoding.Encode implementation]: https://github.com/golang/go/blob/3b5954c6349d31465dca409b45ab6597e0942d9f/src/encoding/base64/base64.go#L139-L189 +func appendBase64(dst *strings.Builder, s string) { + const encode = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + + dst.Grow(base64.StdEncoding.EncodedLen(len(s))) + + i := 0 + for ; i+2 < len(s); i += 3 { + n := uint32(s[i])<<16 | uint32(s[i+1])<<8 | uint32(s[i+2]) + _ = dst.WriteByte(encode[n>>18&0x3f]) + _ = dst.WriteByte(encode[n>>12&0x3f]) + _ = dst.WriteByte(encode[n>>6&0x3f]) + _ = dst.WriteByte(encode[n&0x3f]) + } + + switch len(s) - i { + case 1: + n := uint32(s[i]) << 16 + _ = dst.WriteByte(encode[n>>18&0x3f]) + _ = dst.WriteByte(encode[n>>12&0x3f]) + _ = dst.WriteByte('=') + _ = dst.WriteByte('=') + case 2: + n := uint32(s[i])<<16 | uint32(s[i+1])<<8 + _ = dst.WriteByte(encode[n>>18&0x3f]) + _ = dst.WriteByte(encode[n>>12&0x3f]) + _ = dst.WriteByte(encode[n>>6&0x3f]) + _ = dst.WriteByte('=') + } +} + // MarshalJSON returns the JSON encoding of the Value. func (v Value) MarshalJSON() ([]byte, error) { var jsonVal struct { diff --git a/attribute/value_test.go b/attribute/value_test.go index 64ead4125..7ac2092ff 100644 --- a/attribute/value_test.go +++ b/attribute/value_test.go @@ -93,6 +93,12 @@ func TestValue(t *testing.T) { wantType: attribute.BYTESLICE, wantValue: []byte("hello world"), }, + { + name: "Key.Slice() correctly returns keys's internal []Value value", + value: k.Slice(attribute.BoolValue(true), attribute.IntValue(42), attribute.StringValue("foo")).Value, + wantType: attribute.SLICE, + wantValue: []attribute.Value{attribute.BoolValue(true), attribute.IntValue(42), attribute.StringValue("foo")}, + }, { name: "empty value", value: attribute.Value{}, @@ -100,14 +106,15 @@ func TestValue(t *testing.T) { wantValue: nil, }, } { - t.Logf("Running test case %s", testcase.name) - if testcase.value.Type() != testcase.wantType { - t.Errorf("wrong value type, got %#v, expected %#v", testcase.value.Type(), testcase.wantType) - } - got := testcase.value.AsInterface() - if diff := cmp.Diff(testcase.wantValue, got); diff != "" { - t.Errorf("+got, -want: %s", diff) - } + t.Run(testcase.name, func(t *testing.T) { + if testcase.value.Type() != testcase.wantType { + t.Errorf("wrong value type, got %#v, expected %#v", testcase.value.Type(), testcase.wantType) + } + got := testcase.value.AsInterface() + if diff := cmp.Diff(testcase.wantValue, got, cmp.AllowUnexported(attribute.Value{})); diff != "" { + t.Errorf("+got, -want: %s", diff) + } + }) } } @@ -157,6 +164,18 @@ func TestEquivalence(t *testing.T) { attribute.ByteSlice("ByteSlice", []byte("one")), attribute.ByteSlice("ByteSlice", []byte("one")), }, + { + attribute.Slice("Slice", + attribute.BoolValue(true), + attribute.IntValue(42), + attribute.SliceValue(attribute.StringValue("nested")), + ), + attribute.Slice("Slice", + attribute.BoolValue(true), + attribute.IntValue(42), + attribute.SliceValue(attribute.StringValue("nested")), + ), + }, { attribute.KeyValue{Key: "Empty"}, attribute.KeyValue{Key: "Empty"}, @@ -256,6 +275,10 @@ func TestNotEquivalence(t *testing.T) { attribute.StringSlice("StringSlice", []string{"one", "two", "three"}), attribute.StringSlice("StringSlice", []string{"one", "two"}), }, + { + attribute.Slice("Slice", attribute.BoolValue(true), attribute.IntValue(42)), + attribute.Slice("Slice", attribute.BoolValue(true), attribute.IntValue(43)), + }, { attribute.KeyValue{Key: "Empty"}, attribute.String("Empty", ""), @@ -335,6 +358,63 @@ func TestAsSlice(t *testing.T) { kv = attribute.ByteSlice("ByteSlice", b1) b2 := kv.Value.AsByteSlice() assert.Equal(t, b1, b2) + + for _, tc := range []struct { + name string + in []attribute.Value + }{ + { + name: "empty", + in: []attribute.Value{}, + }, + { + name: "len1", + in: []attribute.Value{attribute.BoolValue(true)}, + }, + { + name: "len2", + in: []attribute.Value{attribute.BoolValue(true), attribute.IntValue(42)}, + }, + { + name: "len3", + in: []attribute.Value{attribute.BoolValue(true), attribute.IntValue(42), attribute.StringValue("test")}, + }, + { + name: "len4", + in: []attribute.Value{ + attribute.BoolValue(true), + attribute.IntValue(42), + attribute.StringValue("test"), + attribute.Float64Value(1.25), + }, + }, + { + name: "len5", + in: []attribute.Value{ + attribute.BoolValue(true), + attribute.IntValue(42), + attribute.StringValue("test"), + attribute.Float64Value(1.25), + attribute.ByteSliceValue([]byte("bin")), + }, + }, + { + name: "reflect path", + in: []attribute.Value{ + attribute.BoolValue(true), + attribute.IntValue(42), + attribute.StringValue("test"), + attribute.Float64Value(1.25), + attribute.ByteSliceValue([]byte("bin")), + attribute.SliceValue(attribute.BoolValue(false)), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + kv = attribute.Slice("Slice", tc.in...) + assert.Equal(t, tc.in, kv.Value.AsSlice()) + }) + } } func TestValueString(t *testing.T) { @@ -560,6 +640,115 @@ func TestValueString(t *testing.T) { v: attribute.ByteSliceValue(nil), want: "", }, + { + name: "empty slice", + v: attribute.SliceValue(), + want: "[]", + }, + { + name: "slice len5 fast path", + v: attribute.SliceValue( + attribute.BoolValue(true), + attribute.IntValue(7), + attribute.Float64Value(math.Copysign(0, -1)), + attribute.StringValue(`hello "world"`), + attribute.ByteSliceValue([]byte("bin")), + ), + want: `[true,7,-0,"hello \"world\"","Ymlu"]`, + }, + { + name: "slice len1 fast path", + v: attribute.SliceValue(attribute.BoolValue(false)), + want: `[false]`, + }, + { + name: "slice len2 fast path", + v: attribute.SliceValue( + attribute.IntValue(7), + attribute.StringValue(`hello "world"`), + ), + want: `[7,"hello \"world\""]`, + }, + { + name: "slice len3 fast path", + v: attribute.SliceValue( + attribute.Float64Value(1.25), + attribute.Float64Value(math.Inf(1)), + attribute.Float64Value(math.Inf(-1)), + ), + want: `[1.25,"Infinity","-Infinity"]`, + }, + { + name: "slice", + v: attribute.SliceValue( + attribute.StringValue("hello \"world\""), + attribute.Float64Value(math.NaN()), + attribute.ByteSliceValue([]byte("bin")), + attribute.SliceValue(attribute.BoolValue(true), attribute.Value{}), + ), + want: `["hello \"world\"","NaN","Ymlu",[true,null]]`, + }, + { + name: "slice reflect path nested slice values", + v: attribute.SliceValue( + attribute.BoolSliceValue([]bool{}), + attribute.BoolSliceValue([]bool{true}), + attribute.BoolSliceValue([]bool{true, false}), + attribute.BoolSliceValue([]bool{true, false, true}), + attribute.BoolSliceValue([]bool{false, true, false, true}), + attribute.Int64SliceValue([]int64{}), + attribute.Int64SliceValue([]int64{-1}), + attribute.Int64SliceValue([]int64{1, -2}), + attribute.Int64SliceValue([]int64{1, -2, 3}), + attribute.Int64SliceValue([]int64{1, -2, 3, -4}), + attribute.Float64SliceValue([]float64{}), + attribute.Float64SliceValue([]float64{math.Inf(-1)}), + attribute.Float64SliceValue([]float64{math.NaN(), math.Inf(1)}), + attribute.Float64SliceValue([]float64{1.25, math.Copysign(0, -1), 2.5}), + attribute.Float64SliceValue([]float64{1, math.NaN(), math.Inf(1), math.Inf(-1)}), + attribute.StringSliceValue([]string{}), + attribute.StringSliceValue([]string{""}), + attribute.StringSliceValue([]string{`hello "world"`, "line\nbreak"}), + attribute.StringSliceValue([]string{"snowman ☃", "left\u2028right", "left\u2029right"}), + attribute.StringSliceValue([]string{ + "tab\treturn\rformfeed\fbackslash\\quote\"backspace\b", + string([]byte{0x01}) + "\u2029", + "&", + string([]byte{'a', 0xff, 'b'}), + }), + attribute.SliceValue(), + attribute.SliceValue(attribute.BoolValue(true)), + attribute.SliceValue(attribute.BoolValue(true), attribute.IntValue(2)), + attribute.SliceValue(attribute.BoolValue(true), attribute.IntValue(2), attribute.StringValue("x")), + attribute.SliceValue( + attribute.BoolValue(true), + attribute.IntValue(2), + attribute.StringValue("x"), + attribute.Float64Value(math.Inf(1)), + ), + attribute.SliceValue( + attribute.BoolValue(true), + attribute.IntValue(2), + attribute.StringValue("x"), + attribute.Float64Value(math.Inf(1)), + attribute.ByteSliceValue([]byte("bin")), + ), + attribute.SliceValue( + attribute.BoolValue(true), + attribute.IntValue(2), + attribute.StringValue("x"), + attribute.Float64Value(math.Inf(1)), + attribute.ByteSliceValue([]byte("bin")), + attribute.Value{}, + ), + ), + want: `[[],[true],[true,false],[true,false,true],[false,true,false,true],[]` + + `,[-1],[1,-2],[1,-2,3],[1,-2,3,-4],[]` + + `,["-Infinity"],["NaN","Infinity"],[1.25,-0,2.5],[1,"NaN","Infinity","-Infinity"],[]` + + `,[""],["hello \"world\"","line\nbreak"],["snowman ☃","left\u2028right","left\u2029right"]` + + `,["tab\treturn\rformfeed\fbackslash\\quote\"backspace\b","\u0001\u2029","&","a\ufffdb"]` + + `,[],[true],[true,2],[true,2,"x"],[true,2,"x","Infinity"],[true,2,"x","Infinity","Ymlu"],[true,2,"x","Infinity","Ymlu",null]]`, + }, { name: "empty", v: attribute.Value{}, diff --git a/log/keyvalue.go b/log/keyvalue.go index 8354f2252..ebf4fcbee 100644 --- a/log/keyvalue.go +++ b/log/keyvalue.go @@ -431,6 +431,13 @@ func ValueFromAttribute(value attribute.Value) Value { case attribute.BYTESLICE: val := value.AsByteSlice() return BytesValue(val) + case attribute.SLICE: + val := value.AsSlice() + res := make([]Value, 0, len(val)) + for _, v := range val { + res = append(res, ValueFromAttribute(v)) + } + return SliceValue(res...) } // This code should never be reached // as log attributes are a superset of standard attributes. diff --git a/log/keyvalue_bench_test.go b/log/keyvalue_bench_test.go index ac925bac2..f746fee60 100644 --- a/log/keyvalue_bench_test.go +++ b/log/keyvalue_bench_test.go @@ -275,6 +275,14 @@ func BenchmarkKeyValueFromAttribute(b *testing.B) { desc: "StringSlice", kv: attribute.StringSlice("k", []string{"foo", "bar"}), }, + { + desc: "Slice", + kv: attribute.Slice("k", + attribute.BoolValue(true), + attribute.StringValue("foo"), + attribute.SliceValue(attribute.IntValue(7)), + ), + }, } for _, tc := range testCases { b.Run(tc.desc, func(b *testing.B) { diff --git a/log/keyvalue_test.go b/log/keyvalue_test.go index cdd0384ec..91218e3cf 100644 --- a/log/keyvalue_test.go +++ b/log/keyvalue_test.go @@ -366,6 +366,19 @@ func TestValueFromAttribute(t *testing.T) { v: attribute.ByteSliceValue([]byte("foo")), want: log.BytesValue([]byte("foo")), }, + { + desc: "Slice", + v: attribute.SliceValue( + attribute.BoolValue(true), + attribute.StringValue("foo"), + attribute.SliceValue(attribute.IntValue(7)), + ), + want: log.SliceValue( + log.BoolValue(true), + log.StringValue("foo"), + log.SliceValue(log.Int64Value(7)), + ), + }, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { @@ -428,6 +441,20 @@ func TestKeyValueFromAttribute(t *testing.T) { kv: attribute.StringSlice("k", []string{"foo", "bar"}), want: log.Slice("k", log.StringValue("foo"), log.StringValue("bar")), }, + { + desc: "Slice", + kv: attribute.Slice("k", + attribute.BoolValue(true), + attribute.StringValue("foo"), + attribute.SliceValue(attribute.IntValue(7)), + ), + want: log.Slice( + "k", + log.BoolValue(true), + log.StringValue("foo"), + log.SliceValue(log.Int64Value(7)), + ), + }, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { diff --git a/sdk/metric/metricdata/metricdatatest/comparisons.go b/sdk/metric/metricdata/metricdatatest/comparisons.go index 4033175e2..51b946980 100644 --- a/sdk/metric/metricdata/metricdatatest/comparisons.go +++ b/sdk/metric/metricdata/metricdatatest/comparisons.go @@ -585,6 +585,10 @@ func equalKeyValue(a, b attribute.KeyValue) bool { if ok := slices.Equal(a.Value.AsByteSlice(), b.Value.AsByteSlice()); !ok { return false } + case attribute.SLICE: + if ok := slices.Equal(a.Value.AsSlice(), b.Value.AsSlice()); !ok { + return false + } case attribute.EMPTY: default: // We control all types passed to this, panic to signal developers diff --git a/trace/auto.go b/trace/auto.go index af402d6a8..483078584 100644 --- a/trace/auto.go +++ b/trace/auto.go @@ -345,6 +345,13 @@ func convAttrValue(value attribute.Value) telemetry.Value { out = append(out, telemetry.StringValue(v)) } return telemetry.SliceValue(out...) + case attribute.SLICE: + slice := value.AsSlice() + out := make([]telemetry.Value, 0, len(slice)) + for _, v := range slice { + out = append(out, convAttrValue(v)) + } + return telemetry.SliceValue(out...) } return telemetry.Value{} }