diff --git a/CHANGELOG.md b/CHANGELOG.md index 243ecbc9820..78601740530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added - Add `ByteSlice` and `ByteSliceValue` functions for new `BYTESLICE` attribute type in `go.opentelemetry.io/otel/attribute`. (#7948) +- Add `String` method for `Value` type in `go.opentelemetry.io/otel/attribute`. (#8142) - Add `Error` field on `Record` type in `go.opentelemetry.io/otel/log/logtest`. (#8148) ### Changed diff --git a/attribute/benchmark_test.go b/attribute/benchmark_test.go index ce5448d6274..54da5092845 100644 --- a/attribute/benchmark_test.go +++ b/attribute/benchmark_test.go @@ -34,6 +34,15 @@ func benchmarkEmit(kv attribute.KeyValue) func(*testing.B) { } } +func benchmarkString(kv attribute.KeyValue) func(*testing.B) { + return func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + outStr = kv.Value.String() + } + } +} + func BenchmarkBool(b *testing.B) { k, v := "bool", true kv := attribute.Bool(k, v) @@ -56,6 +65,7 @@ func BenchmarkBool(b *testing.B) { outBool = kv.Value.AsBool() } }) + b.Run("String", benchmarkString(kv)) b.Run("Emit", benchmarkEmit(kv)) } @@ -89,6 +99,7 @@ func BenchmarkBoolSlice(b *testing.B) { outBoolSlice = kv.Value.AsBoolSlice() } }) + b.Run("String", benchmarkString(kv)) b.Run("Emit", benchmarkEmit(kv)) }) } @@ -110,6 +121,7 @@ func BenchmarkInt(b *testing.B) { outKV = attribute.Int(k, v) } }) + b.Run("String", benchmarkString(kv)) b.Run("Emit", benchmarkEmit(kv)) } @@ -137,6 +149,7 @@ func BenchmarkIntSlice(b *testing.B) { outKV = attribute.IntSlice(k, v) } }) + b.Run("String", benchmarkString(kv)) b.Run("Emit", benchmarkEmit(kv)) }) } @@ -164,6 +177,7 @@ func BenchmarkInt64(b *testing.B) { outInt64 = kv.Value.AsInt64() } }) + b.Run("String", benchmarkString(kv)) b.Run("Emit", benchmarkEmit(kv)) } @@ -197,6 +211,7 @@ func BenchmarkInt64Slice(b *testing.B) { outInt64Slice = kv.Value.AsInt64Slice() } }) + b.Run("String", benchmarkString(kv)) b.Run("Emit", benchmarkEmit(kv)) }) } @@ -224,6 +239,7 @@ func BenchmarkFloat64(b *testing.B) { outFloat64 = kv.Value.AsFloat64() } }) + b.Run("String", benchmarkString(kv)) b.Run("Emit", benchmarkEmit(kv)) } @@ -257,6 +273,7 @@ func BenchmarkFloat64Slice(b *testing.B) { outFloat64Slice = kv.Value.AsFloat64Slice() } }) + b.Run("String", benchmarkString(kv)) b.Run("Emit", benchmarkEmit(kv)) }) } @@ -284,6 +301,7 @@ func BenchmarkString(b *testing.B) { outStr = kv.Value.AsString() } }) + b.Run("String", benchmarkString(kv)) b.Run("Emit", benchmarkEmit(kv)) } @@ -317,6 +335,7 @@ func BenchmarkStringSlice(b *testing.B) { outStrSlice = kv.Value.AsStringSlice() } }) + b.Run("String", benchmarkString(kv)) b.Run("Emit", benchmarkEmit(kv)) }) } @@ -347,6 +366,7 @@ func BenchmarkByteSlice(b *testing.B) { } }) + b.Run("String", benchmarkString(kv)) b.Run("Emit", benchmarkEmit(kv)) } diff --git a/attribute/value.go b/attribute/value.go index c0d340592e2..1c69a72df06 100644 --- a/attribute/value.go +++ b/attribute/value.go @@ -7,7 +7,11 @@ import ( "encoding/base64" "encoding/json" "fmt" + "math" + "reflect" "strconv" + "strings" + "unicode/utf8" attribute "go.opentelemetry.io/otel/attribute/internal" ) @@ -268,6 +272,44 @@ func (v Value) AsInterface() any { return unknownValueType{} } +// String returns a string representation of Value using the +// [OpenTelemetry AnyValue representation for non-OTLP protocols] rules. +// +// 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. Floating-point special values inside arrays are encoded as JSON +// strings. +// +// [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 { + switch v.Type() { + case BOOL: + return strconv.FormatBool(v.AsBool()) + case BOOLSLICE: + return formatBoolSliceValue(v.slice) + case INT64: + return strconv.FormatInt(v.AsInt64(), 10) + case INT64SLICE: + return formatInt64SliceValue(v.slice) + case FLOAT64: + return formatFloat64(v.AsFloat64()) + case FLOAT64SLICE: + return formatFloat64SliceValue(v.slice) + case STRING: + return v.stringly + case STRINGSLICE: + return formatStringSliceValue(v.slice) + case BYTESLICE: + return base64.StdEncoding.EncodeToString(v.asByteSlice()) + case EMPTY: + return "" + default: + return "unknown" + } +} + // Emit returns a string representation of Value's data. func (v Value) Emit() string { switch v.Type() { @@ -308,6 +350,346 @@ func (v Value) Emit() string { } } +const ( + jsonArrayBracketsLen = len("[]") + boolArrayElemMaxLen = len("false") + int64ArrayElemMaxLen = len("-9223372036854775808") + float64ArrayElemMaxLen = len("-1.7976931348623157e+308") + commaLen = len(",") +) + +func formatBoolSliceValue(v any) string { + switch vals := v.(type) { + case [0]bool: + return "[]" + case [1]bool: + return formatBoolSlice(vals[:]) + case [2]bool: + return formatBoolSlice(vals[:]) + case [3]bool: + return formatBoolSlice(vals[:]) + default: + return formatBoolSliceReflect(v) + } +} + +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(']') + 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++ { + if i > 0 { + _ = b.WriteByte(',') + } + if rv.Index(i).Bool() { + _, _ = b.WriteString("true") + } else { + _, _ = b.WriteString("false") + } + } + _ = b.WriteByte(']') + return b.String() +} + +func formatInt64SliceValue(v any) string { + switch vals := v.(type) { + case [0]int64: + return "[]" + case [1]int64: + return formatInt64Slice(vals[:]) + case [2]int64: + return formatInt64Slice(vals[:]) + case [3]int64: + return formatInt64Slice(vals[:]) + default: + return formatInt64SliceReflect(v) + } +} + +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(']') + 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('[') + + var scratch [20]byte + for i := 0; i < rv.Len(); i++ { + if i > 0 { + _ = b.WriteByte(',') + } + out := strconv.AppendInt(scratch[:0], rv.Index(i).Int(), 10) + _, _ = b.Write(out) + } + + _ = b.WriteByte(']') + return b.String() +} + +func formatFloat64(v float64) string { + switch { + case math.IsNaN(v): + return "NaN" + case math.IsInf(v, 1): + return "Infinity" + case math.IsInf(v, -1): + return "-Infinity" + default: + return strconv.FormatFloat(v, 'g', -1, 64) + } +} + +func formatFloat64SliceValue(v any) string { + switch vals := v.(type) { + case [0]float64: + return "[]" + case [1]float64: + return formatFloat64Slice(vals[:]) + case [2]float64: + return formatFloat64Slice(vals[:]) + case [3]float64: + return formatFloat64Slice(vals[:]) + default: + return formatFloat64SliceReflect(v) + } +} + +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(']') + 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('[') + + var scratch [24]byte + for i := 0; i < rv.Len(); i++ { + if i > 0 { + _ = b.WriteByte(',') + } + val := rv.Index(i).Float() + 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(scratch[:0], val, 'g', -1, 64) + _, _ = b.Write(out) + } + } + + _ = b.WriteByte(']') + return b.String() +} + +func formatStringSliceValue(v any) string { + switch vals := v.(type) { + case [0]string: + return "[]" + case [1]string: + return formatStringSlice(vals[:]) + case [2]string: + return formatStringSlice(vals[:]) + case [3]string: + return formatStringSlice(vals[:]) + default: + return formatStringSliceReflect(v) + } +} + +func formatStringSlice(vals []string) 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('[') + for i, val := range vals { + if i > 0 { + _ = b.WriteByte(',') + } + appendJSONString(&b, val) + } + _ = b.WriteByte(']') + return b.String() +} + +func formatStringSliceReflect(v any) string { + rv := reflect.ValueOf(v) + + 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('[') + for i := 0; i < rv.Len(); i++ { + if i > 0 { + _ = b.WriteByte(',') + } + appendJSONString(&b, rv.Index(i).String()) + } + _ = b.WriteByte(']') + return b.String() +} + +// appendJSONString appends s to dst as a JSON string literal. +// +// This is adapted from the Go standard library's encoding/json +// [appendString implementation]. It keeps the same escaping behavior we need +// here, but writes directly into a strings.Builder and intentionally does not +// apply HTML escaping because the OpenTelemetry non-OTLP AnyValue representation +// only requires JSON array string encoding. We inline this instead of using +// encoding/json so slice formatting avoids allocations and reflection. +// +// [appendString implementation]: https://github.com/golang/go/blob/3b5954c6349d31465dca409b45ab6597e0942d9f/src/encoding/json/encode.go#L998-L1064 +func appendJSONString(dst *strings.Builder, s string) { + const hex = "0123456789abcdef" // For escaping bytes to hex. + + _ = dst.WriteByte('"') + start := 0 + + for i := 0; i < len(s); { + if c := s[i]; c < utf8.RuneSelf { + if c >= 0x20 && c != '\\' && c != '"' { + i++ + continue + } + + if start < i { + _, _ = dst.WriteString(s[start:i]) + } + + switch c { + case '\\', '"': + _ = dst.WriteByte('\\') + _ = dst.WriteByte(c) + case '\b': + _, _ = dst.WriteString(`\b`) + case '\f': + _, _ = dst.WriteString(`\f`) + case '\n': + _, _ = dst.WriteString(`\n`) + case '\r': + _, _ = dst.WriteString(`\r`) + case '\t': + _, _ = dst.WriteString(`\t`) + default: + _, _ = dst.WriteString(`\u00`) + _ = dst.WriteByte(hex[c>>4]) + _ = dst.WriteByte(hex[c&0x0f]) + } + + i++ + start = i + continue + } + + r, size := utf8.DecodeRuneInString(s[i:]) + if r == utf8.RuneError && size == 1 { + if start < i { + _, _ = dst.WriteString(s[start:i]) + } + // Match encoding/json by replacing invalid UTF-8 with U+FFFD. + _, _ = dst.WriteString(`\ufffd`) + i++ + start = i + continue + } + + if r == '\u2028' || r == '\u2029' { + if start < i { + _, _ = dst.WriteString(s[start:i]) + } + // Escape JSONP-sensitive separators unconditionally, like encoding/json. + _, _ = dst.WriteString(`\u202`) + _ = dst.WriteByte(hex[r&0x0f]) + i += size + start = i + continue + } + + i += size + } + + if start < len(s) { + _, _ = dst.WriteString(s[start:]) + } + _ = 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 337e74f7311..64ead41255d 100644 --- a/attribute/value_test.go +++ b/attribute/value_test.go @@ -4,6 +4,7 @@ package attribute_test import ( + "math" "testing" "github.com/google/go-cmp/cmp" @@ -335,3 +336,238 @@ func TestAsSlice(t *testing.T) { b2 := kv.Value.AsByteSlice() assert.Equal(t, b1, b2) } + +func TestValueString(t *testing.T) { + for _, tc := range []struct { + name string + v attribute.Value + want string + }{ + { + name: "bool", + v: attribute.BoolValue(true), + want: "true", + }, + { + name: "bool false", + v: attribute.BoolValue(false), + want: "false", + }, + { + name: "bool slice len1 fast path", + v: attribute.BoolSliceValue([]bool{false}), + want: `[false]`, + }, + { + name: "bool slice len2 fast path", + v: attribute.BoolSliceValue([]bool{true, false}), + want: `[true,false]`, + }, + { + name: "empty bool slice", + v: attribute.BoolSliceValue(nil), + want: "[]", + }, + { + name: "empty bool slice literal", + v: attribute.BoolSliceValue([]bool{}), + want: "[]", + }, + { + name: "bool slice", + v: attribute.BoolSliceValue([]bool{true, false, true}), + want: `[true,false,true]`, + }, + { + name: "bool slice reflect path", + v: attribute.BoolSliceValue([]bool{false, true, false, true}), + want: `[false,true,false,true]`, + }, + { + name: "int64", + v: attribute.Int64Value(-42), + want: "-42", + }, + { + name: "int", + v: attribute.IntValue(7), + want: "7", + }, + { + name: "int64 slice len1 fast path", + v: attribute.Int64SliceValue([]int64{-1}), + want: `[-1]`, + }, + { + name: "int64 slice len2 fast path", + v: attribute.Int64SliceValue([]int64{1, -2}), + want: `[1,-2]`, + }, + { + name: "empty int slice", + v: attribute.IntSliceValue(nil), + want: "[]", + }, + { + name: "empty int slice literal", + v: attribute.IntSliceValue([]int{}), + want: "[]", + }, + { + name: "empty int64 slice literal", + v: attribute.Int64SliceValue([]int64{}), + want: "[]", + }, + { + name: "int slice", + v: attribute.IntSliceValue([]int{1, -2, 3}), + want: `[1,-2,3]`, + }, + { + name: "int64 slice reflect path", + v: attribute.Int64SliceValue([]int64{1, -2, 3, -4}), + want: `[1,-2,3,-4]`, + }, + { + name: "float64", + v: attribute.Float64Value(1.23e10), + want: "1.23e+10", + }, + { + name: "float64 negative zero", + v: attribute.Float64Value(math.Copysign(0, -1)), + want: "-0", + }, + { + name: "float64 NaN", + v: attribute.Float64Value(math.NaN()), + want: "NaN", + }, + { + name: "float64 +Inf", + v: attribute.Float64Value(math.Inf(1)), + want: "Infinity", + }, + { + name: "float64 -Inf", + v: attribute.Float64Value(math.Inf(-1)), + want: "-Infinity", + }, + { + name: "empty float64 slice", + v: attribute.Float64SliceValue(nil), + want: "[]", + }, + { + name: "empty float64 slice literal", + v: attribute.Float64SliceValue([]float64{}), + want: "[]", + }, + { + name: "float64 slice len1 fast path", + v: attribute.Float64SliceValue([]float64{math.Inf(-1)}), + want: `["-Infinity"]`, + }, + { + name: "float64 slice len3 fast path", + v: attribute.Float64SliceValue([]float64{1.25, math.Copysign(0, -1), 2.5}), + want: `[1.25,-0,2.5]`, + }, + { + name: "float64 slice", + v: attribute.Float64SliceValue([]float64{ + 1, + math.NaN(), + math.Inf(1), + math.Inf(-1), + math.Copysign(0, -1), + }), + want: `[1,"NaN","Infinity","-Infinity",-0]`, + }, + { + name: "float64 slice fast path", + v: attribute.Float64SliceValue([]float64{ + math.NaN(), + math.Inf(1), + }), + want: `["NaN","Infinity"]`, + }, + { + name: "string", + v: attribute.StringValue(`hello "world"`), + want: `hello "world"`, + }, + { + name: "empty string", + v: attribute.StringValue(""), + want: "", + }, + { + name: "empty string slice", + v: attribute.StringSliceValue(nil), + want: "[]", + }, + { + name: "empty string slice literal", + v: attribute.StringSliceValue([]string{}), + want: "[]", + }, + { + name: "string slice len1 fast path", + v: attribute.StringSliceValue([]string{""}), + want: `[""]`, + }, + { + name: "string slice len3 fast path", + v: attribute.StringSliceValue([]string{"snowman ☃", "left\u2028right", "left\u2029right"}), + want: `["snowman ☃","left\u2028right","left\u2029right"]`, + }, + { + name: "string slice", + v: attribute.StringSliceValue([]string{ + `hello "world"`, + "line\nbreak", + string([]byte{0xff, 'a'}), + "\u2028", + }), + want: `["hello \"world\"","line\nbreak","\ufffda","\u2028"]`, + }, + { + name: "string slice fast path escapes", + v: attribute.StringSliceValue([]string{ + "tab\treturn\rformfeed\fbackslash\\quote\"backspace\b", + string([]byte{0x01}) + "\u2029", + }), + want: `["tab\treturn\rformfeed\fbackslash\\quote\"backspace\b","\u0001\u2029"]`, + }, + { + name: "string slice leaves HTML characters unescaped", + v: attribute.StringSliceValue([]string{"&"}), + want: `["&"]`, + }, + { + name: "string slice replaces invalid utf8 after copied prefix", + v: attribute.StringSliceValue([]string{string([]byte{'a', 0xff, 'b'})}), + want: `["a\ufffdb"]`, + }, + { + name: "byte slice", + v: attribute.ByteSliceValue([]byte("hello world")), + want: "aGVsbG8gd29ybGQ=", + }, + { + name: "empty byte slice", + v: attribute.ByteSliceValue(nil), + want: "", + }, + { + name: "empty", + v: attribute.Value{}, + want: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.v.String()) + }) + } +}