From 5e9a80b3cedc3aa0536bcadfbace4e2eadca6bd3 Mon Sep 17 00:00:00 2001 From: Nesterov Yehor <134618795+NesterovYehor@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:39:32 +0200 Subject: [PATCH] attribute: add BYTESLICE type support (#7948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #7933 Add BYTES type to https://pkg.go.dev/go.opentelemetry.io/otel/attribute - Introduces BYTES type and byte - Adds Bytes / BytesValue constructors - Implements hashing support - Adds base64 representation in Emit() - Adds test coverage for constructors, hashing, and set equality ``` $ go test -run=^$ -bench=BenchmarkByteSlice goos: linux goarch: amd64 pkg: go.opentelemetry.io/otel/attribute cpu: 13th Gen Intel(R) Core(TM) i7-13800H BenchmarkByteSlice/Value-20 149529567 7.993 ns/op 0 B/op 0 allocs/op BenchmarkByteSlice/KeyValue-20 136973736 8.768 ns/op 0 B/op 0 allocs/op BenchmarkByteSlice/AsByteSlice-20 562915658 2.120 ns/op 0 B/op 0 allocs/op BenchmarkByteSlice/Emit-20 29149410 40.26 ns/op 16 B/op 1 allocs/op PASS ``` --------- Co-authored-by: Tyler Yahn Co-authored-by: Robert PajÄ…k --- CHANGELOG.md | 4 ++++ attribute/benchmark_test.go | 28 ++++++++++++++++++++++++++++ attribute/hash.go | 4 ++++ attribute/hash_test.go | 11 ++++++++++- attribute/key.go | 11 +++++++++++ attribute/key_test.go | 5 +++++ attribute/kv.go | 5 +++++ attribute/kv_test.go | 18 ++++++++++++++++++ attribute/type_string.go | 5 +++-- attribute/value.go | 28 ++++++++++++++++++++++++++++ attribute/value_test.go | 19 +++++++++++++++++++ log/keyvalue.go | 3 +++ 12 files changed, 138 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20edda441..34e6c1030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added + +- Add `ByteSlice` and `ByteSliceValue` functions for new `BYTESLICE` attribute type in `go.opentelemetry.io/otel/attribute`. (#7948) + diff --git a/attribute/benchmark_test.go b/attribute/benchmark_test.go index 01b06fbbb..ce5448d62 100644 --- a/attribute/benchmark_test.go +++ b/attribute/benchmark_test.go @@ -322,6 +322,34 @@ func BenchmarkStringSlice(b *testing.B) { } } +func BenchmarkByteSlice(b *testing.B) { + k, v := "bytes", []byte("forty-two") + kv := attribute.ByteSlice(k, v) + + b.Run("Value", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + attribute.ByteSliceValue(v) + } + }) + + b.Run("KeyValue", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + attribute.ByteSlice(k, v) + } + }) + + b.Run("AsByteSlice", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + kv.Value.AsByteSlice() + } + }) + + 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 b09caaa6d..4409a4c11 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) + byteSliceID uint64 = 6874028470941080415 // "_[]byte_" (little endian) emptyID uint64 = 7305809155345288421 // "__empty_" (little endian) ) @@ -81,6 +82,9 @@ func hashKV(h xxhash.Hash, kv KeyValue) xxhash.Hash { 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) case EMPTY: h = h.Uint64(emptyID) default: diff --git a/attribute/hash_test.go b/attribute/hash_test.go index 15d5a4f15..823d972b2 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 ByteSlice(k, []byte("foo")) }, + func(k string) KeyValue { return ByteSlice(k, []byte("[]i1")) }, func(k string) KeyValue { return KeyValue{Key: Key(k)} }, // Empty value. } @@ -188,7 +190,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 @@ -226,6 +228,13 @@ func FuzzHashKVs(f *testing.F) { } } kvs = append(kvs, Float64Slice("float64slice", float64s)) + case 4: + // Test ByteSlice with variable length. + bytes := make([]byte, len(s)%5) + for i := range bytes { + bytes[i] = byte(i + len(k1)) + } + kvs = append(kvs, ByteSlice("bytes", bytes)) } } diff --git a/attribute/key.go b/attribute/key.go index 80a9e5643..cc4bcb02d 100644 --- a/attribute/key.go +++ b/attribute/key.go @@ -117,6 +117,17 @@ func (k Key) StringSlice(v []string) KeyValue { } } +// ByteSlice creates a KeyValue instance with a BYTESLICE Value. +// +// If creating both a key and value at the same time, use the provided +// convenience function instead -- ByteSlice(name, value). +func (k Key) ByteSlice(v []byte) KeyValue { + return KeyValue{ + Key: k, + Value: ByteSliceValue(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 44ab68711..c33aa266b 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.BYTESLICE`, + v: attribute.ByteSliceValue([]byte("foo")), + want: "Zm9v", + }, { name: `test Key.Emit() can emit a string representing self.EMPTY`, v: attribute.Value{}, diff --git a/attribute/kv.go b/attribute/kv.go index 0cc368018..736c135cb 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) } +// ByteSlice creates a KeyValue with a BYTESLICE Value type. +func ByteSlice(k string, v []byte) KeyValue { + return Key(k).ByteSlice(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 7c4f64cd4..fb61676ce 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: "ByteSlice", + actual: attribute.ByteSlice("k1", []byte{123}), + expected: attribute.KeyValue{ + Key: "k1", + Value: attribute.ByteSliceValue([]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 BYTESLICE type Value should be valid", + valid: true, + kv: attribute.ByteSlice("bytes", []byte{}), + }, } for _, test := range tests { @@ -152,6 +165,10 @@ func TestIncorrectCast(t *testing.T) { name: "StringSlice", val: attribute.BoolSliceValue([]bool{true}), }, + { + name: "ByteSlice", + val: attribute.ByteSliceValue([]byte{123}), + }, { name: "Empty", val: attribute.Value{}, @@ -169,6 +186,7 @@ func TestIncorrectCast(t *testing.T) { tt.val.AsInterface() tt.val.AsString() tt.val.AsStringSlice() + tt.val.AsByteSlice() }) }) } diff --git a/attribute/type_string.go b/attribute/type_string.go index 6c04448d6..bd7816331 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[BYTESLICE-9] } -const _Type_name = "EMPTYBOOLINT64FLOAT64STRINGBOOLSLICEINT64SLICEFLOAT64SLICESTRINGSLICE" +const _Type_name = "EMPTYBOOLINT64FLOAT64STRINGBOOLSLICEINT64SLICEFLOAT64SLICESTRINGSLICEBYTESLICE" -var _Type_index = [...]uint8{0, 5, 9, 14, 21, 27, 36, 46, 58, 69} +var _Type_index = [...]uint8{0, 5, 9, 14, 21, 27, 36, 46, 58, 69, 78} func (i Type) String() string { idx := int(i) - 0 diff --git a/attribute/value.go b/attribute/value.go index db04b1326..c0d340592 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" "strconv" @@ -45,6 +46,8 @@ const ( FLOAT64SLICE // STRINGSLICE is a slice of strings Type Value. STRINGSLICE + // BYTESLICE is a slice of bytes Type Value. + BYTESLICE // INVALID is used for a Value with no value set. // // Deprecated: Use EMPTY instead as an empty value is a valid value. @@ -134,6 +137,14 @@ func StringSliceValue(v []string) Value { return Value{vtype: STRINGSLICE, slice: attribute.SliceValue(v)} } +// ByteSliceValue creates a BYTESLICE Value. +func ByteSliceValue(v []byte) Value { + return Value{ + vtype: BYTESLICE, + stringly: string(v), + } +} + // Type returns a type of the Value. func (v Value) Type() Type { return v.vtype @@ -215,6 +226,19 @@ func (v Value) asStringSlice() []string { return attribute.AsSlice[string](v.slice) } +// AsByteSlice returns the bytes value. Make sure that the Value's type +// is BYTESLICE. +func (v Value) AsByteSlice() []byte { + if v.vtype != BYTESLICE { + return nil + } + return v.asByteSlice() +} + +func (v Value) asByteSlice() []byte { + return []byte(v.stringly) +} + type unknownValueType struct{} // AsInterface returns Value's data as any. @@ -236,6 +260,8 @@ func (v Value) AsInterface() any { return v.stringly case STRINGSLICE: return v.asStringSlice() + case BYTESLICE: + return v.asByteSlice() case EMPTY: return nil } @@ -273,6 +299,8 @@ func (v Value) Emit() string { return string(j) case STRING: return v.stringly + case BYTESLICE: + return base64.StdEncoding.EncodeToString(v.asByteSlice()) case EMPTY: return "" default: diff --git a/attribute/value_test.go b/attribute/value_test.go index 40e75b987..337e74f73 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.ByteSlice() correctly returns keys's internal []byte value", + value: k.ByteSlice([]byte("hello world")).Value, + wantType: attribute.BYTESLICE, + wantValue: []byte("hello world"), + }, { name: "empty value", value: attribute.Value{}, @@ -146,6 +152,10 @@ func TestEquivalence(t *testing.T) { attribute.StringSlice("StringSlice", []string{"one", "two", "three"}), attribute.StringSlice("StringSlice", []string{"one", "two", "three"}), }, + { + attribute.ByteSlice("ByteSlice", []byte("one")), + attribute.ByteSlice("ByteSlice", []byte("one")), + }, { attribute.KeyValue{Key: "Empty"}, attribute.KeyValue{Key: "Empty"}, @@ -229,6 +239,10 @@ func TestNotEquivalence(t *testing.T) { attribute.Float64("Float64", 19.09), attribute.Float64("Float64", 22.09), }, + { + attribute.ByteSlice("ByteSlice", []byte("bytes value")), + attribute.ByteSlice("ByteSlice", []byte("another value")), + }, { attribute.Float64Slice("Float64Slice", []float64{12398.1, -37.1713873737, 3}), attribute.Float64Slice("Float64Slice", []float64{12398.1, -37.1713873737, 5}), @@ -315,4 +329,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.ByteSlice("ByteSlice", b1) + b2 := kv.Value.AsByteSlice() + assert.Equal(t, b1, b2) } diff --git a/log/keyvalue.go b/log/keyvalue.go index dd15ee3b8..8354f2252 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.BYTESLICE: + val := value.AsByteSlice() + return BytesValue(val) } // This code should never be reached // as log attributes are a superset of standard attributes.