diff --git a/CHANGELOG.md b/CHANGELOG.md index 2943d778ec8..e1c464c5e19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The next release will require at least [Go 1.25]. ### Added - Support testing of [Go 1.26]. (#7902) +- Add `Bytes` and `BytesValue` functions for new `BYTES Type` in `go.opentelemetry.io/otel/attribute`. (#7948) diff --git a/attribute/benchmark_test.go b/attribute/benchmark_test.go index 65eda55371e..bf5321f6129 100644 --- a/attribute/benchmark_test.go +++ b/attribute/benchmark_test.go @@ -272,6 +272,34 @@ func BenchmarkStringSlice(b *testing.B) { b.Run("Emit", benchmarkEmit(kv)) } +func BenchmarkBytes(b *testing.B) { + k, v := "bytes", []byte("forty-two") + kv := attribute.Bytes(k, v) + + b.Run("Value", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + attribute.BytesValue(v) + } + }) + + b.Run("KeyValue", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + attribute.Bytes(k, v) + } + }) + + b.Run("AsBytes", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + kv.Value.AsBytes() + } + }) + + b.Run("Emit", benchmarkEmit(kv)) +} + func BenchmarkSetEquals(b *testing.B) { b.Run("Empty", func(b *testing.B) { benchmarkSetEquals(b, attribute.EmptySet()) diff --git a/attribute/hash.go b/attribute/hash.go index 6aa69aeaecf..55ca58f2cb6 100644 --- a/attribute/hash.go +++ b/attribute/hash.go @@ -27,6 +27,7 @@ const ( int64SliceID uint64 = 3762322556277578591 // "_[]int64" (little endian) float64SliceID uint64 = 7308324551835016539 // "[]double" (little endian) stringSliceID uint64 = 7453010373645655387 // "[]string" (little endian) + bytesID uint64 = 6874028470941080415 // "_[]byte_" (little endian) ) // hashKVs returns a new xxHash64 hash of kvs. @@ -80,6 +81,12 @@ func hashKV(h xxhash.Hash, kv KeyValue) xxhash.Hash { for i := 0; i < rv.Len(); i++ { h = h.String(rv.Index(i).String()) } + case BYTES: + h = h.Uint64(bytesID) + rv := reflect.ValueOf(kv.Value.slice) + for i := 0; i < rv.Len(); i++ { + h = h.Uint64(rv.Index(i).Uint()) + } case INVALID: default: // Logging is an alternative, but using the internal logger here diff --git a/attribute/hash_test.go b/attribute/hash_test.go index 4b3b382e01d..08d4bcb33fd 100644 --- a/attribute/hash_test.go +++ b/attribute/hash_test.go @@ -36,6 +36,8 @@ var keyVals = []func(string) KeyValue{ func(k string) KeyValue { return String(k, "bar") }, func(k string) KeyValue { return StringSlice(k, []string{"foo", "bar", "baz"}) }, func(k string) KeyValue { return StringSlice(k, []string{"[]i1"}) }, + func(k string) KeyValue { return Bytes(k, []byte("foo")) }, + func(k string) KeyValue { return Bytes(k, []byte("[]i1")) }, } func TestHashKVsEquality(t *testing.T) { @@ -187,7 +189,7 @@ func FuzzHashKVs(f *testing.F) { // Add slice types based on sliceType parameter if numAttrs > 5 { - switch sliceType % 4 { + switch sliceType % 5 { case 0: // Test BoolSlice with variable length. bools := make([]bool, len(s)%5) // 0-4 elements @@ -225,6 +227,13 @@ func FuzzHashKVs(f *testing.F) { } } kvs = append(kvs, Float64Slice("float64slice", float64s)) + case 4: + // Test Bytes with variable length. + bytes := make([]byte, len(s)%5) + for i := range bytes { + bytes[i] = byte(i + len(k1)) + } + kvs = append(kvs, Bytes("bytes", bytes)) } } diff --git a/attribute/internal/attribute.go b/attribute/internal/attribute.go index 7f5eae877da..b93e4fbc5b0 100644 --- a/attribute/internal/attribute.go +++ b/attribute/internal/attribute.go @@ -39,6 +39,13 @@ func StringSliceValue(v []string) any { return cp.Interface() } +// BytesValue converts a bytes slice into an array with same elements as slice. +func BytesValue(v []byte) any { + cp := reflect.New(reflect.ArrayOf(len(v), reflect.TypeFor[byte]())).Elem() + reflect.Copy(cp, reflect.ValueOf(v)) + return cp.Interface() +} + // AsBoolSlice converts a bool array into a slice into with same elements as array. func AsBoolSlice(v any) []bool { rv := reflect.ValueOf(v) @@ -90,3 +97,16 @@ func AsStringSlice(v any) []string { } return cpy } + +// AsBytes converts a bytes array into a slice into with same elements as array. +func AsBytes(v any) []byte { + rv := reflect.ValueOf(v) + if rv.Type().Kind() != reflect.Array { + return nil + } + cpy := make([]byte, rv.Len()) + if len(cpy) > 0 { + _ = reflect.Copy(reflect.ValueOf(cpy), rv) + } + return cpy +} diff --git a/attribute/internal/attribute_test.go b/attribute/internal/attribute_test.go index e0ebb06439a..3fc59e7c3d3 100644 --- a/attribute/internal/attribute_test.go +++ b/attribute/internal/attribute_test.go @@ -36,11 +36,19 @@ var wrapStringSliceValue = func(v any) any { return nil } +var wrapBytesValue = func(v any) any { + if vi, ok := v.([]byte); ok { + return BytesValue(vi) + } + return nil +} + var ( wrapAsBoolSlice = func(v any) any { return AsBoolSlice(v) } wrapAsInt64Slice = func(v any) any { return AsInt64Slice(v) } wrapAsFloat64Slice = func(v any) any { return AsFloat64Slice(v) } wrapAsStringSlice = func(v any) any { return AsStringSlice(v) } + wrapAsBytes = func(v any) any { return AsBytes(v) } ) func TestSliceValue(t *testing.T) { @@ -69,6 +77,10 @@ func TestSliceValue(t *testing.T) { name: "StringSliceValue() two items", args: args{[]string{"123", "2"}}, want: [2]string{"123", "2"}, fn: wrapStringSliceValue, }, + { + name: "BytesValue() two items", + args: args{v: []byte{1, 2}}, want: [2]byte{1, 2}, fn: wrapBytesValue, + }, { name: "AsBoolSlice() two items", args: args{[2]bool{true, false}}, want: []bool{true, false}, fn: wrapAsBoolSlice, @@ -85,6 +97,10 @@ func TestSliceValue(t *testing.T) { name: "AsStringSlice() two items", args: args{[2]string{"1234", "12"}}, want: []string{"1234", "12"}, fn: wrapAsStringSlice, }, + { + name: "AsBytes() two items", + args: args{[2]byte{1, 2}}, want: []byte{1, 2}, fn: wrapAsBytes, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -142,3 +158,21 @@ func BenchmarkAsFloat64Slice(b *testing.B) { sync = AsFloat64Slice(in) } } + +func BenchmarkBytesValue(b *testing.B) { + b.ReportAllocs() + bs := []byte("foo") + + for b.Loop() { + BytesValue(bs) + } +} + +func BenchmarkAsBytes(b *testing.B) { + b.ReportAllocs() + bs := [2]byte{1, 2} + + for b.Loop() { + AsBytes(bs) + } +} diff --git a/attribute/key.go b/attribute/key.go index 80a9e5643f6..9a78c5b18c3 100644 --- a/attribute/key.go +++ b/attribute/key.go @@ -117,6 +117,17 @@ func (k Key) StringSlice(v []string) KeyValue { } } +// Bytes creates a KeyValue instance with a BYTES Value. +// +// If creating both a key and value at the same time, use the provided +// convenience function instead -- Bytes(name, value). +func (k Key) Bytes(v []byte) KeyValue { + return KeyValue{ + Key: k, + Value: BytesValue(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 576982f98ec..4dd41dc41e2 100644 --- a/attribute/key_test.go +++ b/attribute/key_test.go @@ -108,6 +108,11 @@ func TestEmit(t *testing.T) { v: attribute.StringSliceValue([]string{"foo", "bar"}), want: `["foo","bar"]`, }, + { + name: `test Key.Emit() can emit a string representing self.BYTES`, + v: attribute.BytesValue([]byte("foo")), + want: "Zm9v", + }, } { t.Run(testcase.name, func(t *testing.T) { // proto: func (v attribute.Value) Emit() string { diff --git a/attribute/kv.go b/attribute/kv.go index 8c6928ca79b..a2e572114c5 100644 --- a/attribute/kv.go +++ b/attribute/kv.go @@ -68,6 +68,11 @@ func StringSlice(k string, v []string) KeyValue { return Key(k).StringSlice(v) } +// Bytes creates a KeyValue with a BYTES Value type. +func Bytes(k string, v []byte) KeyValue { + return Key(k).Bytes(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 3a2e948141d..3f0d5004107 100644 --- a/attribute/kv_test.go +++ b/attribute/kv_test.go @@ -58,6 +58,14 @@ func TestKeyValueConstructors(t *testing.T) { Value: attribute.IntValue(123), }, }, + { + name: "Bytes", + actual: attribute.Bytes("k1", []byte{123}), + expected: attribute.KeyValue{ + Key: "k1", + Value: attribute.BytesValue([]byte{123}), + }, + }, } for _, test := range tt { @@ -114,6 +122,11 @@ func TestKeyValueValid(t *testing.T) { valid: true, kv: attribute.String("string", ""), }, + { + desc: "non-empty key with BYTE type Value should be valid", + valid: true, + kv: attribute.Bytes("bytes", []byte{}), + }, } for _, test := range tests { @@ -152,6 +165,10 @@ func TestIncorrectCast(t *testing.T) { name: "StringSlice", val: attribute.BoolSliceValue([]bool{true}), }, + { + name: "Bytes", + val: attribute.BytesValue([]byte{123}), + }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { @@ -165,6 +182,7 @@ func TestIncorrectCast(t *testing.T) { tt.val.AsInterface() tt.val.AsString() tt.val.AsStringSlice() + tt.val.AsBytes() }) }) } diff --git a/attribute/type_string.go b/attribute/type_string.go index 24f1fa37dbe..9604a53de77 100644 --- a/attribute/type_string.go +++ b/attribute/type_string.go @@ -17,11 +17,12 @@ func _() { _ = x[INT64SLICE-6] _ = x[FLOAT64SLICE-7] _ = x[STRINGSLICE-8] + _ = x[BYTES-9] } -const _Type_name = "INVALIDBOOLINT64FLOAT64STRINGBOOLSLICEINT64SLICEFLOAT64SLICESTRINGSLICE" +const _Type_name = "INVALIDBOOLINT64FLOAT64STRINGBOOLSLICEINT64SLICEFLOAT64SLICESTRINGSLICEBYTES" -var _Type_index = [...]uint8{0, 7, 11, 16, 23, 29, 38, 48, 60, 71} +var _Type_index = [...]uint8{0, 7, 11, 16, 23, 29, 38, 48, 60, 71, 76} func (i Type) String() string { idx := int(i) - 0 diff --git a/attribute/value.go b/attribute/value.go index 5931e71291a..7da1c1c2953 100644 --- a/attribute/value.go +++ b/attribute/value.go @@ -4,6 +4,7 @@ package attribute // import "go.opentelemetry.io/otel/attribute" import ( + "encoding/base64" "encoding/json" "fmt" "reflect" @@ -44,6 +45,8 @@ const ( FLOAT64SLICE // STRINGSLICE is a slice of strings Type Value. STRINGSLICE + // BYTES is a slice of bytes Type Value. + BYTES ) // BoolValue creates a BOOL Value. @@ -115,6 +118,14 @@ func StringSliceValue(v []string) Value { return Value{vtype: STRINGSLICE, slice: attribute.StringSliceValue(v)} } +// BytesValue creates a BYTES Value. +func BytesValue(v []byte) Value { + return Value{ + vtype: BYTES, + slice: attribute.BytesValue(v), + } +} + // Type returns a type of the Value. func (v Value) Type() Type { return v.vtype @@ -196,6 +207,19 @@ func (v Value) asStringSlice() []string { return attribute.AsStringSlice(v.slice) } +// AsBytes returns the bytes value. Make sure that the Value's type +// is BYTES. +func (v Value) AsBytes() []byte { + if v.vtype != BYTES { + return nil + } + return v.asBytes() +} + +func (v Value) asBytes() []byte { + return attribute.AsBytes(v.slice) +} + type unknownValueType struct{} // AsInterface returns Value's data as any. @@ -217,6 +241,8 @@ func (v Value) AsInterface() any { return v.stringly case STRINGSLICE: return v.asStringSlice() + case BYTES: + return v.asBytes() } return unknownValueType{} } @@ -252,6 +278,8 @@ func (v Value) Emit() string { return string(j) case STRING: return v.stringly + case BYTES: + return base64.StdEncoding.EncodeToString(v.asBytes()) default: return "unknown" } diff --git a/attribute/value_test.go b/attribute/value_test.go index 2f2a9e6a757..13811051096 100644 --- a/attribute/value_test.go +++ b/attribute/value_test.go @@ -86,6 +86,12 @@ func TestValue(t *testing.T) { wantType: attribute.STRINGSLICE, wantValue: []string{"forty-two", "negative three", "twelve"}, }, + { + name: "Key.Bytes() correctly returns keys's internal []byte value", + value: k.Bytes([]byte("hello world")).Value, + wantType: attribute.BYTES, + wantValue: []byte("hello world"), + }, } { t.Logf("Running test case %s", testcase.name) if testcase.value.Type() != testcase.wantType { @@ -143,6 +149,10 @@ func TestEquivalence(t *testing.T) { attribute.StringSlice("StringSlice", []string{"one", "two", "three"}), attribute.StringSlice("StringSlice", []string{"one", "two", "three"}), }, + { + attribute.Bytes("Bytes", []byte("one")), + attribute.Bytes("Bytes", []byte("one")), + }, } t.Run("Distinct", func(t *testing.T) { @@ -205,4 +215,9 @@ func TestAsSlice(t *testing.T) { kv = attribute.StringSlice("StringSlice", ss1) ss2 := kv.Value.AsStringSlice() assert.Equal(t, ss1, ss2) + + b1 := []byte("one") + kv = attribute.Bytes("Bytes", b1) + b2 := kv.Value.AsBytes() + assert.Equal(t, b1, b2) } diff --git a/log/keyvalue.go b/log/keyvalue.go index f87cee04d60..54e604fa22e 100644 --- a/log/keyvalue.go +++ b/log/keyvalue.go @@ -428,6 +428,9 @@ func ValueFromAttribute(value attribute.Value) Value { res = append(res, StringValue(v)) } return SliceValue(res...) + case attribute.BYTES: + val := value.AsBytes() + return BytesValue(val) } // This code should never be reached // as log attributes are a superset of standard attributes.