1
0
mirror of https://github.com/open-telemetry/opentelemetry-go.git synced 2026-06-19 21:45:50 +02:00

attribute: add MAP type support (#8445)

Fixes https://github.com/open-telemetry/opentelemetry-go/issues/7935

```
$ go test -run=^$ -bench=BenchmarkMap
goos: linux
goarch: amd64
pkg: go.opentelemetry.io/otel/attribute
cpu: 13th Gen Intel(R) Core(TM) i7-13800H
BenchmarkMap/Len3/Value-20      12423774               143.1 ns/op           192 B/op          1 allocs/op
BenchmarkMap/Len3/KeyValue-20            8123966               170.4 ns/op           192 B/op          1 allocs/op
BenchmarkMap/Len3/AsMap-20              12685639               111.0 ns/op           192 B/op          1 allocs/op
BenchmarkMap/Len3/String-20              4482690               250.1 ns/op           128 B/op          1 allocs/op
BenchmarkMap/Len3/Emit-20                5927258               205.5 ns/op           128 B/op          1 allocs/op
BenchmarkMap/Len5Nested/Value-20         2773639               387.1 ns/op           320 B/op          1 allocs/op
BenchmarkMap/Len5Nested/KeyValue-20      3093283               404.7 ns/op           320 B/op          1 allocs/op
BenchmarkMap/Len5Nested/AsMap-20         8646662               161.1 ns/op           320 B/op          1 allocs/op
BenchmarkMap/Len5Nested/String-20        2785362               499.1 ns/op           208 B/op          1 allocs/op
BenchmarkMap/Len5Nested/Emit-20          2882395               375.2 ns/op           208 B/op          1 allocs/op
```

```
$ go test -run=^$ -bench=BenchmarkHashValueMap
goos: linux
goarch: amd64
pkg: go.opentelemetry.io/otel/attribute
cpu: 13th Gen Intel(R) Core(TM) i7-13800H
BenchmarkHashValueMap/Len2-20            7589696               133.0 ns/op            80 B/op          1 allocs/op
BenchmarkHashValueMap/Len5-20            6254871               205.7 ns/op            80 B/op          1 allocs/op
BenchmarkHashValueMap/Len8Nested-20      2726431               424.1 ns/op            80 B/op          1 allocs/op
```
This commit is contained in:
Robert Pająk
2026-06-12 10:07:11 +02:00
committed by GitHub
parent 0ce87de251
commit 51b03667ac
18 changed files with 877 additions and 8 deletions
+1
View File
@@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
### Added
- Add `Map` and `MapValue` functions for new `MAP` attribute type in `go.opentelemetry.io/otel/attribute`. (#8445)
- Add `WithUnsafeAttributes` to `go.opentelemetry.io/otel/metric/x` as an experimental no-copy attribute option intended for future performance work. This is a work in progress. (#8251)
### Fixed
+53
View File
@@ -25,6 +25,7 @@ var (
outStr string
outStrSlice []string
outValueSlice []attribute.Value
outMap []attribute.KeyValue
)
func benchmarkEmit(kv attribute.KeyValue) func(*testing.B) {
@@ -395,6 +396,58 @@ func BenchmarkSlice(b *testing.B) {
}
}
func BenchmarkMap(b *testing.B) {
for _, bench := range []struct {
name string
v []attribute.KeyValue
}{
{
name: "Len3",
v: []attribute.KeyValue{
attribute.Bool("enabled", true),
attribute.Int("count", 42),
attribute.String("name", "test"),
},
},
{
name: "Len5Nested",
v: []attribute.KeyValue{
attribute.String("quote", "quote\""),
attribute.Float64("inf", math.Inf(1)),
attribute.ByteSlice("bin", []byte("bin")),
attribute.Key("nested").Map(attribute.String("key", "value"), attribute.Key("empty").Slice(attribute.Value{})),
attribute.Bool("enabled", false),
},
},
} {
b.Run(bench.name, func(b *testing.B) {
k, v := "map", bench.v
kv := attribute.Map(k, v...)
b.Run("Value", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
outV = attribute.MapValue(v...)
}
})
b.Run("KeyValue", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
outKV = attribute.Map(k, v...)
}
})
b.Run("AsMap", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
outMap = kv.Value.AsMap()
}
})
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)
+32
View File
@@ -29,6 +29,7 @@ const (
stringSliceID uint64 = 7453010373645655387 // "[]string" (little endian)
byteSliceID uint64 = 6874028470941080415 // "_[]byte_" (little endian)
sliceID uint64 = 7883494272577650031 // "__slice_" (little endian)
mapID uint64 = 6872316492666199903 // "__map___" (little endian)
emptyID uint64 = 7305809155345288421 // "__empty_" (little endian)
)
@@ -116,6 +117,29 @@ func hashValue(h xxhash.Hash, v Value) xxhash.Hash {
h = hashValue(h, rv.Index(i).Interface().(Value))
}
}
case MAP:
h = h.Uint64(mapID)
switch vals := v.slice.(type) {
case [0]KeyValue:
// No values to hash, but the type identifier is still hashed above.
case [1]KeyValue:
h = hashMap(h, vals[:])
case [2]KeyValue:
h = hashMap(h, vals[:])
case [3]KeyValue:
h = hashMap(h, vals[:])
case [4]KeyValue:
h = hashMap(h, vals[:])
case [5]KeyValue:
h = hashMap(h, vals[:])
default:
rv := reflect.ValueOf(v.slice)
for i := 0; i < rv.Len(); i++ {
kv := rv.Index(i).Interface().(KeyValue)
h = h.String(string(kv.Key))
h = hashValue(h, kv.Value)
}
}
case EMPTY:
h = h.Uint64(emptyID)
default:
@@ -134,3 +158,11 @@ func hashValueSlice(h xxhash.Hash, vals []Value) xxhash.Hash {
}
return h
}
func hashMap(h xxhash.Hash, vals []KeyValue) xxhash.Hash {
for _, kv := range vals {
h = h.String(string(kv.Key))
h = hashValue(h, kv.Value)
}
return h
}
+139 -2
View File
@@ -95,6 +95,22 @@ var keyVals = []keyVal{
StringSliceValue([]string{"fallback"}),
)
}},
{name: "MapLen0", kv: func(k string) KeyValue { return Map(k) }},
{name: "MapLen2", kv: func(k string) KeyValue {
return Map(
k,
String("b", "two"),
Int("a", 1),
)
}},
{name: "MapNested", kv: func(k string) KeyValue {
return Map(
k,
String("nested", "map"),
Map("child", Float64("f", math.Inf(1)), ByteSlice("bin", []byte("bin"))),
Slice("slice", BoolValue(true), StringValue("tail")),
)
}},
{name: "EmptyValue", kv: func(k string) KeyValue { return KeyValue{Key: Key(k)} }},
}
@@ -153,6 +169,57 @@ func TestHashKVs(t *testing.T) {
}
}
func TestHashValueMapOrdering(t *testing.T) {
tests := []struct {
name string
kvs []KeyValue
}{
{
name: "Len4",
kvs: []KeyValue{
String("d", "4"),
String("a", "1"),
String("c", "3"),
String("b", "2"),
},
},
{
name: "Len5",
kvs: []KeyValue{
String("e", "5"),
String("a", "1"),
String("d", "4"),
String("b", "2"),
String("c", "3"),
},
},
{
name: "Reflect",
kvs: []KeyValue{
String("f", "6"),
String("a", "1"),
String("e", "5"),
String("b", "2"),
String("d", "4"),
String("c", "3"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reversed := slices.Clone(tt.kvs)
slices.Reverse(reversed)
got := hashValue(xxhash.New(), MapValue(tt.kvs...)).Sum64()
want := hashValue(xxhash.New(), MapValue(reversed...)).Sum64()
if got != want {
t.Fatalf("hashValue(MapValue(%v)) = %d, want %d", tt.kvs, got, want)
}
})
}
}
func slice(kvs []KeyValue) string {
if len(kvs) == 0 {
return "[]"
@@ -255,6 +322,53 @@ func BenchmarkHashValueSlice(b *testing.B) {
}
}
func BenchmarkHashValueMap(b *testing.B) {
benches := []struct {
name string
v Value
}{
{
name: "Len2",
v: MapValue(
Bool("two", true),
String("one", "one"),
),
},
{
name: "Len5",
v: MapValue(
Bool("one", true),
Int("two", 2),
String("three", "three"),
Float64("four", 4.5),
ByteSlice("five", []byte("five")),
),
},
{
name: "Len8Nested",
v: MapValue(
Bool("one", true),
Int("two", 2),
String("three", "three"),
Float64("four", 4.5),
ByteSlice("five", []byte("five")),
Map("six", String("nested", "map"), Int64("value", 6)),
Slice("seven", StringValue("nested"), BoolValue(true)),
StringSlice("eight", []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))
@@ -293,9 +407,9 @@ func FuzzHashKVs(f *testing.F) {
kvs = append(kvs, Bool(k5, b))
}
// Add slice types based on sliceType parameter.
// Add slice and composite types based on sliceType parameter.
if numAttrs > 5 {
switch sliceType % 6 {
switch sliceType % 7 {
case 0:
// Test BoolSlice with variable length.
bools := make([]bool, len(s)%5) // 0-4 elements
@@ -355,6 +469,22 @@ func FuzzHashKVs(f *testing.F) {
}
}
kvs = append(kvs, Slice("slice", values...))
case 6:
values := make([]KeyValue, len(s)%4) // 0-3 elements
for i := range values {
key := fmt.Sprintf("item_%d", i)
switch i % 4 {
case 0:
values[i] = Bool(key, (i+len(k1))%2 == 0)
case 1:
values[i] = Int(key, i+len(k2))
case 2:
values[i] = String(key, fmt.Sprintf("item_%d", i))
case 3:
values[i] = Map(key, Float64("float", fVal), ByteSlice("bin", []byte("bin")))
}
}
kvs = append(kvs, Map("map", values...))
}
}
@@ -452,6 +582,13 @@ func FuzzHashKVs(f *testing.F) {
}
modifiedKvs[0] = Slice(string(modifiedKvs[0].Key), newSlice...)
}
case MAP:
origMap := modifiedKvs[0].Value.AsMap()
if len(origMap) > 0 {
newMap := slices.Clone(origMap)
newMap[0] = String(string(newMap[0].Key), "modified")
modifiedKvs[0] = Map(string(modifiedKvs[0].Key), newMap...)
}
case EMPTY:
modifiedKvs[0] = String(string(modifiedKvs[0].Key), "not_empty")
}
+14
View File
@@ -139,6 +139,20 @@ func (k Key) Slice(v ...Value) KeyValue {
}
}
// Map creates a KeyValue instance with a MAP Value.
//
// Users should avoid providing duplicate keys; many receivers handle maps
// containing duplicate keys unpredictably.
//
// If creating both a key and value at the same time, use the provided
// convenience function instead -- Map(name, values...).
func (k Key) Map(v ...KeyValue) KeyValue {
return KeyValue{
Key: k,
Value: MapValue(v...),
}
}
// Defined reports whether the key is not empty.
func (k Key) Defined() bool {
return len(k) != 0
+20
View File
@@ -124,6 +124,15 @@ func TestEmit(t *testing.T) {
),
want: `[true,"foo\"bar","Infinity","Ymlu"]`,
},
{
name: `test Key.Emit() can emit a string representing self.MAP`,
v: attribute.MapValue(
attribute.String("b", "foo\"bar"),
attribute.Float64("a", math.Inf(1)),
attribute.Key("bytes").ByteSlice([]byte("bin")),
),
want: `{"a":"Infinity","b":"foo\"bar","bytes":"Ymlu"}`,
},
{
name: `test Key.Emit() can emit a string representing self.EMPTY`,
v: attribute.Value{},
@@ -151,4 +160,15 @@ func TestString(t *testing.T) {
)
require.Equal(t, `["foo\nbar","NaN",["Ymlu",null]]`, v.String())
m := attribute.MapValue(
attribute.String("foo\nbar", "value"),
attribute.Float64("nan", math.NaN()),
attribute.Key("nested").Map(
attribute.Key("bytes").ByteSlice([]byte("bin")),
attribute.Key("empty").Slice(attribute.Value{}),
),
)
require.JSONEq(t, `{"foo\nbar":"value","nan":"NaN","nested":{"bytes":"Ymlu","empty":[null]}}`, m.String())
}
+8
View File
@@ -78,6 +78,14 @@ func Slice(k string, v ...Value) KeyValue {
return Key(k).Slice(v...)
}
// Map creates a KeyValue with a MAP Value type.
//
// Users should avoid providing duplicate keys; many receivers handle maps
// containing duplicate keys unpredictably.
func Map(k string, v ...KeyValue) KeyValue {
return Key(k).Map(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 {
+21
View File
@@ -74,6 +74,17 @@ func TestKeyValueConstructors(t *testing.T) {
Value: attribute.SliceValue(attribute.BoolValue(true), attribute.IntValue(42)),
},
},
{
name: "Map",
actual: attribute.Map("k1", attribute.String("b", "two"), attribute.Int("a", 1)),
expected: attribute.KeyValue{
Key: "k1",
Value: attribute.MapValue(
attribute.Int("a", 1),
attribute.String("b", "two"),
),
},
},
}
for _, test := range tt {
@@ -140,6 +151,11 @@ func TestKeyValueValid(t *testing.T) {
valid: true,
kv: attribute.Slice("slice", attribute.StringValue("value")),
},
{
desc: "non-empty key with MAP type Value should be valid",
valid: true,
kv: attribute.Map("map", attribute.String("key", "value")),
},
}
for _, test := range tests {
@@ -190,6 +206,10 @@ func TestIncorrectCast(t *testing.T) {
name: "Slice",
val: attribute.SliceValue(attribute.StringValue("value")),
},
{
name: "Map",
val: attribute.MapValue(attribute.String("key", "value")),
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
@@ -201,6 +221,7 @@ func TestIncorrectCast(t *testing.T) {
tt.val.AsInt64()
tt.val.AsInt64Slice()
tt.val.AsInterface()
tt.val.AsMap()
tt.val.AsSlice()
tt.val.AsString()
tt.val.AsStringSlice()
+3 -2
View File
@@ -19,11 +19,12 @@ func _() {
_ = x[STRINGSLICE-8]
_ = x[BYTESLICE-9]
_ = x[SLICE-10]
_ = x[MAP-11]
}
const _Type_name = "EMPTYBOOLINT64FLOAT64STRINGBOOLSLICEINT64SLICEFLOAT64SLICESTRINGSLICEBYTESLICESLICE"
const _Type_name = "EMPTYBOOLINT64FLOAT64STRINGBOOLSLICEINT64SLICEFLOAT64SLICESTRINGSLICEBYTESLICESLICEMAP"
var _Type_index = [...]uint8{0, 5, 9, 14, 21, 27, 36, 46, 58, 69, 78, 83}
var _Type_index = [...]uint8{0, 5, 9, 14, 21, 27, 36, 46, 58, 69, 78, 83, 86}
func (i Type) String() string {
idx := int(i) - 0
+208 -4
View File
@@ -4,11 +4,13 @@
package attribute // import "go.opentelemetry.io/otel/attribute"
import (
"cmp"
"encoding/base64"
"encoding/json"
"fmt"
"math"
"reflect"
"slices"
"strconv"
"strings"
"unicode/utf8"
@@ -54,6 +56,11 @@ const (
BYTESLICE
// SLICE is a slice of Value Type values.
SLICE
// MAP is a slice of KeyValue values representing a map of heterogeneous values.
//
// Note that MAP values may contain duplicate keys if duplicate keys are
// provided when creating the value.
MAP
// INVALID is used for a Value with no value set.
//
// Deprecated: Use EMPTY instead as an empty value is a valid value.
@@ -156,6 +163,14 @@ func SliceValue(v ...Value) Value {
return Value{vtype: SLICE, slice: sliceValue(v)}
}
// MapValue creates a MAP Value.
//
// Users should avoid providing duplicate keys; many receivers handle maps
// containing duplicate keys unpredictably.
func MapValue(v ...KeyValue) Value {
return Value{vtype: MAP, slice: mapValue(v)}
}
// Type returns a type of the Value.
func (v Value) Type() Type {
return v.vtype
@@ -277,6 +292,53 @@ func asValueSliceReflect(v any) []Value {
return cpy
}
// AsMap returns the []KeyValue value. Make sure that the Value's type is
// MAP.
//
// The returned slice is sorted by key and may differ from the order
// provided when creating the map value.
//
// The returned slice may contain duplicate keys if duplicate keys were
// provided when creating the map value. Callers should not assume the returned
// keys are unique.
func (v Value) AsMap() []KeyValue {
if v.vtype != MAP {
return nil
}
return v.asMap()
}
func (v Value) asMap() []KeyValue {
switch vals := v.slice.(type) {
case [0]KeyValue:
return []KeyValue{}
case [1]KeyValue:
return []KeyValue{vals[0]}
case [2]KeyValue:
return []KeyValue{vals[0], vals[1]}
case [3]KeyValue:
return []KeyValue{vals[0], vals[1], vals[2]}
case [4]KeyValue:
return []KeyValue{vals[0], vals[1], vals[2], vals[3]}
case [5]KeyValue:
return []KeyValue{vals[0], vals[1], vals[2], vals[3], vals[4]}
default:
return asKeyValueSliceReflect(v.slice)
}
}
func asKeyValueSliceReflect(v any) []KeyValue {
rv := reflect.ValueOf(v)
if !rv.IsValid() || rv.Kind() != reflect.Array || rv.Type().Elem() != reflect.TypeFor[KeyValue]() {
return nil
}
cpy := make([]KeyValue, 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 {
@@ -315,6 +377,8 @@ func (v Value) AsInterface() any {
return v.asByteSlice()
case SLICE:
return v.asSlice()
case MAP:
return v.asMap()
case EMPTY:
return nil
}
@@ -327,10 +391,10 @@ func (v Value) AsInterface() any {
// Strings are returned as-is without JSON quoting, booleans and integers use
// 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. String, byte, and special floating-point values inside arrays
// are encoded as JSON strings, and empty values inside arrays are encoded as
// null.
// base64-encoded, empty values are the empty string, slices are encoded as JSON
// arrays, and maps are encoded as JSON objects. String, byte, and special
// floating-point values inside arrays and maps are encoded as JSON strings, and
// empty values inside arrays and maps 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 {
@@ -355,6 +419,8 @@ func (v Value) String() string {
return formatByteSlice(v.stringly)
case SLICE:
return formatValueSliceValue(v.slice)
case MAP:
return formatMapValue(v.slice)
case EMPTY:
return ""
default:
@@ -399,6 +465,8 @@ func (v Value) Emit() string {
return formatByteSlice(v.stringly)
case SLICE:
return formatValueSliceValue(v.slice)
case MAP:
return formatMapValue(v.slice)
case EMPTY:
return ""
default:
@@ -439,6 +507,47 @@ func sliceValueReflect(v []Value) any {
return cp.Interface()
}
func mapValue(v []KeyValue) any {
switch len(v) {
case 0:
return [0]KeyValue{}
case 1:
return [1]KeyValue{v[0]}
case 2:
vals := [2]KeyValue{v[0], v[1]}
sortKeyValues(vals[:])
return vals
case 3:
vals := [3]KeyValue{v[0], v[1], v[2]}
sortKeyValues(vals[:])
return vals
case 4:
vals := [4]KeyValue{v[0], v[1], v[2], v[3]}
sortKeyValues(vals[:])
return vals
case 5:
vals := [5]KeyValue{v[0], v[1], v[2], v[3], v[4]}
sortKeyValues(vals[:])
return vals
default:
return mapValueReflect(v)
}
}
func mapValueReflect(v []KeyValue) any {
cp := reflect.New(reflect.ArrayOf(len(v), reflect.TypeFor[KeyValue]())).Elem()
reflect.Copy(cp, reflect.ValueOf(v))
vals := cp.Slice(0, len(v)).Interface().([]KeyValue)
sortKeyValues(vals)
return cp.Interface()
}
func sortKeyValues(vals []KeyValue) {
slices.SortStableFunc(vals, func(a, b KeyValue) int {
return cmp.Compare(a.Key, b.Key)
})
}
func formatBoolSliceValue(v any) string {
switch vals := v.(type) {
case [0]bool:
@@ -807,6 +916,37 @@ func formatValueSliceReflect(v any) string {
return b.String()
}
func formatMapValue(v any) string {
switch vals := v.(type) {
case [0]KeyValue:
return "{}"
case [1]KeyValue:
return formatMap(vals[:])
case [2]KeyValue:
return formatMap(vals[:])
case [3]KeyValue:
return formatMap(vals[:])
case [4]KeyValue:
return formatMap(vals[:])
case [5]KeyValue:
return formatMap(vals[:])
default:
return formatMapReflect(v)
}
}
func formatMap(vals []KeyValue) string {
var b strings.Builder
appendMap(&b, vals)
return b.String()
}
func formatMapReflect(v any) string {
var b strings.Builder
appendMapReflect(&b, reflect.ValueOf(v))
return b.String()
}
func appendValueSliceValue(dst *strings.Builder, v any) {
switch vals := v.(type) {
case [0]Value:
@@ -852,6 +992,68 @@ func appendValueSliceReflect(dst *strings.Builder, rv reflect.Value) {
_ = dst.WriteByte(']')
}
func appendMapValue(dst *strings.Builder, v any) {
switch vals := v.(type) {
case [0]KeyValue:
_, _ = dst.WriteString("{}")
case [1]KeyValue:
appendMap(dst, vals[:])
case [2]KeyValue:
appendMap(dst, vals[:])
case [3]KeyValue:
appendMap(dst, vals[:])
case [4]KeyValue:
appendMap(dst, vals[:])
case [5]KeyValue:
appendMap(dst, vals[:])
default:
appendMapReflect(dst, reflect.ValueOf(v))
}
}
func appendMap(dst *strings.Builder, vals []KeyValue) {
// Estimate 32 bytes per value for small values, plus key quotes, colon,
// and commas. Escaped keys and larger values grow the builder as needed.
size := len("{}") + len(vals)*commaLen + len(vals)*32
for _, val := range vals {
size += len(val.Key) + len(`"":`)
}
dst.Grow(size)
_ = dst.WriteByte('{')
for i, val := range vals {
if i > 0 {
_ = dst.WriteByte(',')
}
appendJSONString(dst, string(val.Key))
_ = dst.WriteByte(':')
appendJSONValue(dst, val.Value)
}
_ = dst.WriteByte('}')
}
func appendMapReflect(dst *strings.Builder, rv reflect.Value) {
// Estimate 32 bytes per value for small values, plus key quotes, colon,
// and commas. Escaped keys and larger values grow the builder as needed.
size := len("{}") + rv.Len()*commaLen + rv.Len()*32
for i := 0; i < rv.Len(); i++ {
size += len(rv.Index(i).Field(0).String()) + len(`"":`)
}
dst.Grow(size)
_ = dst.WriteByte('{')
for i := 0; i < rv.Len(); i++ {
if i > 0 {
_ = dst.WriteByte(',')
}
val := rv.Index(i).Interface().(KeyValue)
appendJSONString(dst, string(val.Key))
_ = dst.WriteByte(':')
appendJSONValue(dst, val.Value)
}
_ = dst.WriteByte('}')
}
func appendJSONValue(dst *strings.Builder, v Value) {
switch v.Type() {
case BOOL:
@@ -894,6 +1096,8 @@ func appendJSONValue(dst *strings.Builder, v Value) {
_ = dst.WriteByte('"')
case SLICE:
appendValueSliceValue(dst, v.slice)
case MAP:
appendMapValue(dst, v.slice)
case EMPTY:
_, _ = dst.WriteString("null")
default:
+256
View File
@@ -99,6 +99,15 @@ func TestValue(t *testing.T) {
wantType: attribute.SLICE,
wantValue: []attribute.Value{attribute.BoolValue(true), attribute.IntValue(42), attribute.StringValue("foo")},
},
{
name: "Key.Map() correctly returns keys's internal []KeyValue value",
value: k.Map(attribute.String("b", "two"), attribute.Int("a", 1)).Value,
wantType: attribute.MAP,
wantValue: []attribute.KeyValue{
attribute.Int("a", 1),
attribute.String("b", "two"),
},
},
{
name: "empty value",
value: attribute.Value{},
@@ -178,6 +187,18 @@ func TestEquivalence(t *testing.T) {
attribute.SliceValue(attribute.StringValue("nested")),
),
},
{
attribute.Map(
"Map",
attribute.Bool("b", true),
attribute.Map("a", attribute.String("nested", "value")),
),
attribute.Map(
"Map",
attribute.Map("a", attribute.String("nested", "value")),
attribute.Bool("b", true),
),
},
{
attribute.KeyValue{Key: "Empty"},
attribute.KeyValue{Key: "Empty"},
@@ -281,6 +302,14 @@ func TestNotEquivalence(t *testing.T) {
attribute.Slice("Slice", attribute.BoolValue(true), attribute.IntValue(42)),
attribute.Slice("Slice", attribute.BoolValue(true), attribute.IntValue(43)),
},
{
attribute.Map("Map", attribute.String("key", "value")),
attribute.Map("Map", attribute.String("key", "other")),
},
{
attribute.Map("Map", attribute.String("key", "value")),
attribute.Map("Map", attribute.String("other", "value")),
},
{
attribute.KeyValue{Key: "Empty"},
attribute.String("Empty", ""),
@@ -419,6 +448,131 @@ func TestAsSlice(t *testing.T) {
}
}
func TestAsMap(t *testing.T) {
for _, tc := range []struct {
name string
in []attribute.KeyValue
want []attribute.KeyValue
}{
{
name: "empty",
},
{
name: "len1",
in: []attribute.KeyValue{attribute.Bool("a", true)},
want: []attribute.KeyValue{attribute.Bool("a", true)},
},
{
name: "len2 sorted",
in: []attribute.KeyValue{
attribute.Int("b", 2),
attribute.Int("a", 1),
},
want: []attribute.KeyValue{
attribute.Int("a", 1),
attribute.Int("b", 2),
},
},
{
name: "len4 sorted",
in: []attribute.KeyValue{
attribute.String("d", "4"),
attribute.String("a", "1"),
attribute.String("c", "3"),
attribute.String("b", "2"),
},
want: []attribute.KeyValue{
attribute.String("a", "1"),
attribute.String("b", "2"),
attribute.String("c", "3"),
attribute.String("d", "4"),
},
},
{
name: "len5 sorted",
in: []attribute.KeyValue{
attribute.String("e", "5"),
attribute.String("a", "1"),
attribute.String("d", "4"),
attribute.String("b", "2"),
attribute.String("c", "3"),
},
want: []attribute.KeyValue{
attribute.String("a", "1"),
attribute.String("b", "2"),
attribute.String("c", "3"),
attribute.String("d", "4"),
attribute.String("e", "5"),
},
},
{
name: "reflect path sorted",
in: []attribute.KeyValue{
attribute.String("f", "6"),
attribute.String("a", "1"),
attribute.String("e", "5"),
attribute.String("b", "2"),
attribute.String("d", "4"),
attribute.String("c", "3"),
},
want: []attribute.KeyValue{
attribute.String("a", "1"),
attribute.String("b", "2"),
attribute.String("c", "3"),
attribute.String("d", "4"),
attribute.String("e", "5"),
attribute.String("f", "6"),
},
},
{
name: "duplicate keys keep stable order",
in: []attribute.KeyValue{
attribute.String("dup", "first"),
attribute.String("a", "before"),
attribute.String("dup", "second"),
},
want: []attribute.KeyValue{
attribute.String("a", "before"),
attribute.String("dup", "first"),
attribute.String("dup", "second"),
},
},
{
name: "empty keys and values",
in: []attribute.KeyValue{
attribute.String("z", "last"),
{Key: "empty-value"},
attribute.String("", "empty-key"),
{},
},
want: []attribute.KeyValue{
attribute.String("", "empty-key"),
{},
{Key: "empty-value"},
attribute.String("z", "last"),
},
},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.want == nil {
tc.want = []attribute.KeyValue{}
}
v := attribute.MapValue(tc.in...)
if len(tc.in) > 0 {
tc.in[0] = attribute.String("mutated", "input")
}
assert.Equal(t, tc.want, v.AsMap())
got := v.AsMap()
if len(got) > 0 {
got[0] = attribute.String("mutated", "output")
}
assert.Equal(t, tc.want, v.AsMap())
})
}
}
func TestValueString(t *testing.T) {
for _, tc := range []struct {
name string
@@ -751,6 +905,108 @@ func TestValueString(t *testing.T) {
`,["tab\treturn\rformfeed\fbackslash\\quote\"backspace\b","\u0001\u2029","<tag>&","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 map",
v: attribute.MapValue(),
want: "{}",
},
{
name: "map with empty key",
v: attribute.MapValue(attribute.String("", "value")),
want: `{"":"value"}`,
},
{
name: "map with empty value",
v: attribute.MapValue(attribute.KeyValue{Key: "empty"}),
want: `{"empty":null}`,
},
{
name: "map with empty key and value",
v: attribute.MapValue(attribute.KeyValue{}),
want: `{"":null}`,
},
{
name: "map len2 sorted",
v: attribute.MapValue(
attribute.Int("b", 2),
attribute.String("a", `hello "world"`),
),
want: `{"a":"hello \"world\"","b":2}`,
},
{
name: "map len4 fast path",
v: attribute.MapValue(
attribute.String("d", "4"),
attribute.String("a", "1"),
attribute.String("c", "3"),
attribute.String("b", "2"),
),
want: `{"a":"1","b":"2","c":"3","d":"4"}`,
},
{
name: "map len5 fast path",
v: attribute.MapValue(
attribute.String("e", "5"),
attribute.String("a", "1"),
attribute.String("d", "4"),
attribute.String("b", "2"),
attribute.String("c", "3"),
),
want: `{"a":"1","b":"2","c":"3","d":"4","e":"5"}`,
},
{
name: "map escapes keys",
v: attribute.MapValue(
attribute.String("line\nkey", "value"),
attribute.Bool("<tag>&", true),
),
want: `{"<tag>&":true,"line\nkey":"value"}`,
},
{
name: "map reflect path nested values",
v: attribute.MapValue(
attribute.String("z", "last"),
attribute.Key("bytes").ByteSlice([]byte("bin")),
attribute.Key("empty").Slice(attribute.Value{}),
attribute.Key("float").Float64(math.Inf(1)),
attribute.Key("map").Map(attribute.String("nested", "value")),
attribute.Key("slice").Slice(attribute.IntValue(1), attribute.StringValue("two")),
),
want: `{"bytes":"Ymlu","empty":[null],"float":"Infinity","map":{"nested":"value"},"slice":[1,"two"],"z":"last"}`,
},
{
name: "slice nested map storage paths",
v: attribute.SliceValue(
attribute.MapValue(),
attribute.MapValue(
attribute.String("c", "3"),
attribute.String("a", "1"),
attribute.String("b", "2"),
),
attribute.MapValue(
attribute.String("d", "4"),
attribute.String("a", "1"),
attribute.String("c", "3"),
attribute.String("b", "2"),
),
attribute.MapValue(
attribute.String("e", "5"),
attribute.String("a", "1"),
attribute.String("d", "4"),
attribute.String("b", "2"),
attribute.String("c", "3"),
),
attribute.MapValue(
attribute.String("f", "6"),
attribute.String("a", "1"),
attribute.String("e", "5"),
attribute.String("b", "2"),
attribute.String("d", "4"),
attribute.String("c", "3"),
),
),
want: `[{},{"a":"1","b":"2","c":"3"},{"a":"1","b":"2","c":"3","d":"4"},{"a":"1","b":"2","c":"3","d":"4","e":"5"},{"a":"1","b":"2","c":"3","d":"4","e":"5","f":"6"}]`,
},
{
name: "empty",
v: attribute.Value{},
+7
View File
@@ -438,6 +438,13 @@ func ValueFromAttribute(value attribute.Value) Value {
res = append(res, ValueFromAttribute(v))
}
return SliceValue(res...)
case attribute.MAP:
val := value.AsMap()
res := make([]KeyValue, 0, len(val))
for _, kv := range val {
res = append(res, KeyValueFromAttribute(kv))
}
return MapValue(res...)
}
// This code should never be reached
// as log attributes are a superset of standard attributes.
+9
View File
@@ -284,6 +284,15 @@ func BenchmarkKeyValueFromAttribute(b *testing.B) {
attribute.SliceValue(attribute.IntValue(7)),
),
},
{
desc: "Map",
kv: attribute.Map(
"k",
attribute.String("b", "two"),
attribute.Key("a").Map(attribute.Int("nested", 1)),
attribute.Key("slice").Slice(attribute.BoolValue(true)),
),
},
}
for _, tc := range testCases {
b.Run(tc.desc, func(b *testing.B) {
+28
View File
@@ -379,6 +379,19 @@ func TestValueFromAttribute(t *testing.T) {
log.SliceValue(log.Int64Value(7)),
),
},
{
desc: "Map",
v: attribute.MapValue(
attribute.String("b", "two"),
attribute.Key("a").Map(attribute.Int("nested", 1)),
attribute.Key("slice").Slice(attribute.BoolValue(true)),
),
want: log.MapValue(
log.Map("a", log.Int("nested", 1)),
log.String("b", "two"),
log.Slice("slice", log.BoolValue(true)),
),
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
@@ -456,6 +469,21 @@ func TestKeyValueFromAttribute(t *testing.T) {
log.SliceValue(log.Int64Value(7)),
),
},
{
desc: "Map",
kv: attribute.Map(
"k",
attribute.String("b", "two"),
attribute.Key("a").Map(attribute.Int("nested", 1)),
attribute.Key("slice").Slice(attribute.BoolValue(true)),
),
want: log.Map(
"k",
log.Map("a", log.Int("nested", 1)),
log.String("b", "two"),
log.Slice("slice", log.BoolValue(true)),
),
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
@@ -851,6 +851,34 @@ func TestEqualKeyValue(t *testing.T) {
b: attribute.ByteSlice("bytes", []byte{1, 2, 4}),
want: false,
},
{
name: "map equal",
a: attribute.Map(
"map",
attribute.String("b", "two"),
attribute.Key("a").Map(attribute.Int("nested", 1)),
),
b: attribute.Map(
"map",
attribute.Key("a").Map(attribute.Int("nested", 1)),
attribute.String("b", "two"),
),
want: true,
},
{
name: "map different value",
a: attribute.Map(
"map",
attribute.String("b", "two"),
attribute.Key("a").Map(attribute.Int("nested", 1)),
),
b: attribute.Map(
"map",
attribute.Key("a").Map(attribute.Int("nested", 2)),
attribute.String("b", "two"),
),
want: false,
},
{
name: "empty",
a: attribute.KeyValue{},
@@ -589,6 +589,10 @@ func equalKeyValue(a, b attribute.KeyValue) bool {
if ok := slices.Equal(a.Value.AsSlice(), b.Value.AsSlice()); !ok {
return false
}
case attribute.MAP:
if ok := slices.EqualFunc(a.Value.AsMap(), b.Value.AsMap(), equalKeyValue); !ok {
return false
}
case attribute.EMPTY:
default:
// We control all types passed to this, panic to signal developers
+10
View File
@@ -358,6 +358,16 @@ func convAttrValue(value attribute.Value) telemetry.Value {
out = append(out, convAttrValue(v))
}
return telemetry.SliceValue(out...)
case attribute.MAP:
kvs := value.AsMap()
out := make([]telemetry.Attr, 0, len(kvs))
for _, kv := range kvs {
out = append(out, telemetry.Attr{
Key: string(kv.Key),
Value: convAttrValue(kv.Value),
})
}
return telemetry.MapValue(out...)
}
return telemetry.Value{}
}
+36
View File
@@ -243,6 +243,42 @@ func TestConvAttrValueSlice(t *testing.T) {
}
}
func TestConvAttrValueMap(t *testing.T) {
t.Parallel()
val := convAttrValue(attribute.MapValue(
attribute.String("b", "two"),
attribute.Key("a").Map(attribute.Int("nested", 1)),
attribute.Key("slice").Slice(attribute.BoolValue(true)),
))
assert.Equal(t, telemetry.ValueKindMap, val.Kind())
kvs := val.AsMap()
if assert.Len(t, kvs, 3) {
assert.Equal(t, "a", kvs[0].Key)
assert.Equal(t, telemetry.ValueKindMap, kvs[0].Value.Kind())
nested := kvs[0].Value.AsMap()
if assert.Len(t, nested, 1) {
assert.Equal(t, "nested", nested[0].Key)
assert.Equal(t, telemetry.ValueKindInt64, nested[0].Value.Kind())
assert.EqualValues(t, 1, nested[0].Value.AsInt64())
}
assert.Equal(t, "b", kvs[1].Key)
assert.Equal(t, telemetry.ValueKindString, kvs[1].Value.Kind())
assert.Equal(t, "two", kvs[1].Value.AsString())
assert.Equal(t, "slice", kvs[2].Key)
assert.Equal(t, telemetry.ValueKindSlice, kvs[2].Value.Kind())
slice := kvs[2].Value.AsSlice()
if assert.Len(t, slice, 1) {
assert.Equal(t, telemetry.ValueKindBool, slice[0].Kind())
assert.True(t, slice[0].AsBool())
}
}
}
func TestTracerStartPropagatesOrigCtx(t *testing.T) {
t.Parallel()