diff --git a/CHANGELOG.md b/CHANGELOG.md index d4a2cbe71b1..3ae864a57b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,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 `String` method for `KeyValue` type in `go.opentelemetry.io/otel/attribute`. (#8205) - Add `Slice` and `SliceValue` functions for new `SLICE` attribute type in `go.opentelemetry.io/otel/attribute`. (#8166) - Support `SLICE` attributes in `go.opentelemetry.io/otel/exporters/otlp/otlptrace`. (#8216) - Support `SLICE` attributes in `go.opentelemetry.io/otel/exporters/otlp/otlplog`. (#8216) diff --git a/attribute/benchmark_test.go b/attribute/benchmark_test.go index 0c9c3ca862d..fbddea54d0f 100644 --- a/attribute/benchmark_test.go +++ b/attribute/benchmark_test.go @@ -37,15 +37,30 @@ func benchmarkEmit(kv attribute.KeyValue) func(*testing.B) { } } -func benchmarkString(kv attribute.KeyValue) func(*testing.B) { +func benchmarkValueString(v attribute.Value) func(*testing.B) { return func(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { - outStr = kv.Value.String() + outStr = v.String() } } } +func benchmarkKeyValueString(kv attribute.KeyValue) func(*testing.B) { + return func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + outStr = kv.String() + } + } +} + +func runStringBenchmarks(b *testing.B, kv attribute.KeyValue) { + b.Run("String", benchmarkValueString(kv.Value)) + b.Run("KeyValueString", benchmarkKeyValueString(kv)) + b.Run("Emit", benchmarkEmit(kv)) +} + func BenchmarkBool(b *testing.B) { k, v := "bool", true kv := attribute.Bool(k, v) @@ -68,8 +83,7 @@ func BenchmarkBool(b *testing.B) { outBool = kv.Value.AsBool() } }) - b.Run("String", benchmarkString(kv)) - b.Run("Emit", benchmarkEmit(kv)) + runStringBenchmarks(b, kv) } func BenchmarkBoolSlice(b *testing.B) { @@ -102,8 +116,7 @@ func BenchmarkBoolSlice(b *testing.B) { outBoolSlice = kv.Value.AsBoolSlice() } }) - b.Run("String", benchmarkString(kv)) - b.Run("Emit", benchmarkEmit(kv)) + runStringBenchmarks(b, kv) }) } } @@ -124,8 +137,7 @@ func BenchmarkInt(b *testing.B) { outKV = attribute.Int(k, v) } }) - b.Run("String", benchmarkString(kv)) - b.Run("Emit", benchmarkEmit(kv)) + runStringBenchmarks(b, kv) } func BenchmarkIntSlice(b *testing.B) { @@ -152,8 +164,7 @@ func BenchmarkIntSlice(b *testing.B) { outKV = attribute.IntSlice(k, v) } }) - b.Run("String", benchmarkString(kv)) - b.Run("Emit", benchmarkEmit(kv)) + runStringBenchmarks(b, kv) }) } } @@ -180,8 +191,7 @@ func BenchmarkInt64(b *testing.B) { outInt64 = kv.Value.AsInt64() } }) - b.Run("String", benchmarkString(kv)) - b.Run("Emit", benchmarkEmit(kv)) + runStringBenchmarks(b, kv) } func BenchmarkInt64Slice(b *testing.B) { @@ -214,8 +224,7 @@ func BenchmarkInt64Slice(b *testing.B) { outInt64Slice = kv.Value.AsInt64Slice() } }) - b.Run("String", benchmarkString(kv)) - b.Run("Emit", benchmarkEmit(kv)) + runStringBenchmarks(b, kv) }) } } @@ -242,8 +251,7 @@ func BenchmarkFloat64(b *testing.B) { outFloat64 = kv.Value.AsFloat64() } }) - b.Run("String", benchmarkString(kv)) - b.Run("Emit", benchmarkEmit(kv)) + runStringBenchmarks(b, kv) } func BenchmarkFloat64Slice(b *testing.B) { @@ -276,8 +284,7 @@ func BenchmarkFloat64Slice(b *testing.B) { outFloat64Slice = kv.Value.AsFloat64Slice() } }) - b.Run("String", benchmarkString(kv)) - b.Run("Emit", benchmarkEmit(kv)) + runStringBenchmarks(b, kv) }) } } @@ -304,8 +311,7 @@ func BenchmarkString(b *testing.B) { outStr = kv.Value.AsString() } }) - b.Run("String", benchmarkString(kv)) - b.Run("Emit", benchmarkEmit(kv)) + runStringBenchmarks(b, kv) } func BenchmarkStringSlice(b *testing.B) { @@ -338,8 +344,7 @@ func BenchmarkStringSlice(b *testing.B) { outStrSlice = kv.Value.AsStringSlice() } }) - b.Run("String", benchmarkString(kv)) - b.Run("Emit", benchmarkEmit(kv)) + runStringBenchmarks(b, kv) }) } } @@ -390,8 +395,7 @@ func BenchmarkSlice(b *testing.B) { outValueSlice = kv.Value.AsSlice() } }) - b.Run("String", benchmarkString(kv)) - b.Run("Emit", benchmarkEmit(kv)) + runStringBenchmarks(b, kv) }) } } @@ -473,8 +477,7 @@ func BenchmarkByteSlice(b *testing.B) { } }) - b.Run("String", benchmarkString(kv)) - b.Run("Emit", benchmarkEmit(kv)) + runStringBenchmarks(b, kv) } func BenchmarkSetEquals(b *testing.B) { diff --git a/attribute/kv.go b/attribute/kv.go index 7152cc3a6cb..9162c4e9b08 100644 --- a/attribute/kv.go +++ b/attribute/kv.go @@ -4,7 +4,12 @@ package attribute // import "go.opentelemetry.io/otel/attribute" import ( + "encoding/base64" "fmt" + "math" + "reflect" + "strconv" + "strings" ) // KeyValue holds a key and value pair. @@ -13,6 +18,87 @@ type KeyValue struct { Value Value } +// String returns a string representation of kv using the +// [OpenTelemetry Attribute representation for non-OTLP] rules. +// +// The returned string is a JSON object containing a single key-value pair. +// +// The returned string is meant for debugging; +// the string representation is not stable. +// +// [OpenTelemetry Attribute representation for non-OTLP]: https://opentelemetry.io/docs/specs/otel/common/#attribute-representation-for-non-otlp +func (kv KeyValue) String() string { + const jsonObjectSyntaxLen = len(`{"":}`) + + var b strings.Builder + n := len(kv.Key) + jsonObjectSyntaxLen + switch kv.Value.Type() { + case BOOL: + if kv.Value.AsBool() { + n += len("true") + } else { + n += len("false") + } + case BOOLSLICE: + n += jsonArrayBracketsLen + if l := reflect.ValueOf(kv.Value.slice).Len(); l > 0 { + n += l*boolArrayElemMaxLen + (l-1)*commaLen + } + case INT64: + var buf [int64ArrayElemMaxLen]byte + n += len(strconv.AppendInt(buf[:0], kv.Value.AsInt64(), 10)) + case INT64SLICE: + n += jsonArrayBracketsLen + if l := reflect.ValueOf(kv.Value.slice).Len(); l > 0 { + n += l*int64ArrayElemMaxLen + (l-1)*commaLen + } + case FLOAT64: + val := kv.Value.AsFloat64() + switch { + case math.IsNaN(val): + n += len(`"NaN"`) + case math.IsInf(val, 1): + n += len(`"Infinity"`) + case math.IsInf(val, -1): + n += len(`"-Infinity"`) + default: + var buf [float64ArrayElemMaxLen]byte + n += len(strconv.AppendFloat(buf[:0], val, 'g', -1, 64)) + } + case FLOAT64SLICE: + n += jsonArrayBracketsLen + if l := reflect.ValueOf(kv.Value.slice).Len(); l > 0 { + n += l*float64ArrayElemMaxLen + (l-1)*commaLen + } + case STRING: + n += len(kv.Value.stringly) + quotesLen + case STRINGSLICE: + n += jsonArrayBracketsLen + if l := reflect.ValueOf(kv.Value.slice).Len(); l > 0 { + n += l*smallObjectLen + (l-1)*commaLen + } + case BYTESLICE: + n += base64.StdEncoding.EncodedLen(len(kv.Value.stringly)) + quotesLen + case SLICE: + n += jsonArrayBracketsLen + if l := reflect.ValueOf(kv.Value.slice).Len(); l > 0 { + n += l*smallObjectLen + (l-1)*commaLen + } + case EMPTY: + n += len("null") + default: + n += len(`"unknown"`) + } + b.Grow(n) + + _ = b.WriteByte('{') + appendJSONString(&b, string(kv.Key)) + _ = b.WriteByte(':') + appendJSONValue(&b, kv.Value) + _ = b.WriteByte('}') + return b.String() +} + // Valid reports whether kv is a valid OpenTelemetry attribute. func (kv KeyValue) Valid() bool { return kv.Key.Defined() diff --git a/attribute/kv_test.go b/attribute/kv_test.go index c4b8c23b8bb..11453e5bc7b 100644 --- a/attribute/kv_test.go +++ b/attribute/kv_test.go @@ -4,6 +4,7 @@ package attribute_test import ( + "math" "testing" "github.com/google/go-cmp/cmp" @@ -165,6 +166,129 @@ func TestKeyValueValid(t *testing.T) { } } +func TestKeyValueString(t *testing.T) { + for _, tc := range []struct { + name string + kv attribute.KeyValue + want string + }{ + { + name: "bool true", + kv: attribute.Bool("bool", true), + want: `{"bool":true}`, + }, + { + name: "bool false", + kv: attribute.Bool("bool", false), + want: `{"bool":false}`, + }, + { + name: "bool slice", + kv: attribute.BoolSlice("bools", []bool{true, false}), + want: `{"bools":[true,false]}`, + }, + { + name: "empty bool slice", + kv: attribute.BoolSlice("bools", []bool{}), + want: `{"bools":[]}`, + }, + { + name: "int64", + kv: attribute.Int64("count", -42), + want: `{"count":-42}`, + }, + { + name: "int64 slice", + kv: attribute.Int64Slice("counts", []int64{42, -3}), + want: `{"counts":[42,-3]}`, + }, + { + name: "empty int64 slice", + kv: attribute.Int64Slice("counts", []int64{}), + want: `{"counts":[]}`, + }, + { + name: "float64", + kv: attribute.Float64("float", 3.14), + want: `{"float":3.14}`, + }, + { + name: "string", + kv: attribute.String("greeting", `hello "world"`), + want: `{"greeting":"hello \"world\""}`, + }, + { + name: "string slice", + kv: attribute.StringSlice("strings", []string{"one", "two"}), + want: `{"strings":["one","two"]}`, + }, + { + name: "empty string slice", + kv: attribute.StringSlice("strings", []string{}), + want: `{"strings":[]}`, + }, + { + name: "byte slice", + kv: attribute.ByteSlice("bytes", []byte("one")), + want: `{"bytes":"b25l"}`, + }, + { + name: "float64 slice", + kv: attribute.Float64Slice("floats", []float64{1.5, -2.25}), + want: `{"floats":[1.5,-2.25]}`, + }, + { + name: "empty float64 slice", + kv: attribute.Float64Slice("floats", []float64{}), + want: `{"floats":[]}`, + }, + { + name: "float64 NaN", + kv: attribute.Float64("float", math.NaN()), + want: `{"float":"NaN"}`, + }, + { + name: "float64 Infinity", + kv: attribute.Float64("float", math.Inf(1)), + want: `{"float":"Infinity"}`, + }, + { + name: "float64 negative Infinity", + kv: attribute.Float64("float", math.Inf(-1)), + want: `{"float":"-Infinity"}`, + }, + { + name: "value slice", + kv: attribute.Slice( + "slice", + attribute.StringValue("one"), + attribute.IntValue(2), + attribute.BoolValue(false), + ), + want: `{"slice":["one",2,false]}`, + }, + { + name: "empty value slice", + kv: attribute.Slice("slice"), + want: `{"slice":[]}`, + }, + { + name: "quoted key", + kv: attribute.String(`http.request.method:raw`, "GET"), + want: `{"http.request.method:raw":"GET"}`, + }, + { + name: "empty value", + kv: attribute.KeyValue{Key: "empty"}, + want: `{"empty":null}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.kv.String()) + }) + } +} + func TestIncorrectCast(t *testing.T) { testCases := []struct { name string diff --git a/attribute/value.go b/attribute/value.go index 44101bb4090..ba6252a1e65 100644 --- a/attribute/value.go +++ b/attribute/value.go @@ -480,6 +480,9 @@ const ( int64ArrayElemMaxLen = len("-9223372036854775808") float64ArrayElemMaxLen = len("-1.7976931348623157e+308") commaLen = len(",") + quotesLen = len(`""`) + // estimate for a small JSON value to help with Builder capacity calculations. + smallObjectLen = len(`{"key":"value"}`) ) func sliceValue(v []Value) any { @@ -967,8 +970,8 @@ func appendValueSliceValue(dst *strings.Builder, v any) { } 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) + // Estimate for small values and commas. + dst.Grow(jsonArrayBracketsLen + len(vals)*commaLen + len(vals)*smallObjectLen) _ = dst.WriteByte('[') for i, val := range vals { if i > 0 { @@ -980,8 +983,8 @@ func appendValueSlice(dst *strings.Builder, vals []Value) { } 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) + // Estimate for small values and commas. + dst.Grow(jsonArrayBracketsLen + rv.Len()*commaLen + rv.Len()*smallObjectLen) _ = dst.WriteByte('[') for i := 0; i < rv.Len(); i++ { if i > 0 {