Skip to content

Commit 2b3c72d

Browse files
committed
Add a telemetry.Attributes type
This type allows the serialization and deserialization of a set of OTel span attributes from JSON, handling type validation and upcasting for that scenario.
1 parent 5bf60a6 commit 2b3c72d

File tree

2 files changed

+335
-0
lines changed

2 files changed

+335
-0
lines changed

telemetry/attributes.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package telemetry
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
9+
"go.opentelemetry.io/otel/attribute"
10+
)
11+
12+
var (
13+
ErrUnsupportedValue = fmt.Errorf("unsupported value")
14+
ErrUnsupportedSliceValue = fmt.Errorf("%w: slice attributes may contain only one type", ErrUnsupportedValue)
15+
)
16+
17+
// Attributes is a wrapper around a slice of attribute.KeyValue values which
18+
// serializes to and from a simple JSON dictionary, handling type validation and
19+
// ensuring that JSON numbers are upcast to the appropriate types in the
20+
// attributes (int64 if possible, float64 otherwise).
21+
type Attributes []attribute.KeyValue
22+
23+
func (as Attributes) AsSlice() []attribute.KeyValue {
24+
return []attribute.KeyValue(as)
25+
}
26+
27+
func (as Attributes) MarshalJSON() ([]byte, error) {
28+
attrMap := make(map[string]any)
29+
for _, a := range as {
30+
attrMap[string(a.Key)] = a.Value.AsInterface()
31+
}
32+
return json.Marshal(attrMap)
33+
}
34+
35+
func (as *Attributes) UnmarshalJSON(b []byte) error {
36+
var attrMap map[string]any
37+
38+
d := json.NewDecoder(bytes.NewReader(b))
39+
d.UseNumber() // read JSON numbers into json.Number so we can distinguish int/float
40+
41+
if err := d.Decode(&attrMap); err != nil {
42+
return err
43+
}
44+
45+
kvs := make([]attribute.KeyValue, 0, len(attrMap))
46+
47+
for k, v := range attrMap {
48+
key := attribute.Key(k)
49+
value, err := getValue(v)
50+
if errors.Is(err, ErrUnsupportedValue) {
51+
logger.Sugar().Warnw("skipping unsupported attribute value", "key", k, "error", err)
52+
continue
53+
} else if err != nil {
54+
return err
55+
}
56+
kvs = append(kvs, attribute.KeyValue{Key: key, Value: value})
57+
}
58+
59+
*as = kvs
60+
61+
return nil
62+
}
63+
64+
func getValue(value any) (attribute.Value, error) {
65+
switch v := value.(type) {
66+
case json.Number:
67+
if asInt64, err := v.Int64(); err == nil {
68+
return attribute.Int64Value(asInt64), nil
69+
}
70+
if asFloat64, err := v.Float64(); err == nil {
71+
return attribute.Float64Value(asFloat64), nil
72+
} else {
73+
return attribute.Value{}, err
74+
}
75+
case bool:
76+
return attribute.BoolValue(v), nil
77+
case string:
78+
return attribute.StringValue(v), nil
79+
case []any:
80+
return getSliceValue(v)
81+
default:
82+
return attribute.Value{}, ErrUnsupportedValue
83+
}
84+
}
85+
86+
func getSliceValue(values []any) (attribute.Value, error) {
87+
if len(values) == 0 {
88+
// We have no type information, we arbitrarily decide it's a string slice.
89+
return attribute.StringSliceValue([]string{}), nil
90+
}
91+
92+
var isFloat bool
93+
94+
if _, ok := values[0].(json.Number); ok {
95+
// If it's a json.Number, then we only map to int64 if *all* the values can
96+
// be parsed as ints.
97+
for _, v := range values {
98+
asNumber, ok := v.(json.Number)
99+
if !ok {
100+
return attribute.Value{}, ErrUnsupportedSliceValue
101+
}
102+
if _, err := asNumber.Int64(); err != nil {
103+
isFloat = true
104+
break
105+
}
106+
}
107+
}
108+
109+
switch values[0].(type) {
110+
case json.Number:
111+
if isFloat {
112+
s := make([]float64, len(values))
113+
for i, v := range values {
114+
asNumber, ok := v.(json.Number)
115+
if !ok {
116+
return attribute.Value{}, ErrUnsupportedSliceValue
117+
}
118+
asFloat64, err := asNumber.Float64()
119+
if err != nil {
120+
return attribute.Value{}, err
121+
}
122+
s[i] = asFloat64
123+
}
124+
return attribute.Float64SliceValue(s), nil
125+
}
126+
s := make([]int64, len(values))
127+
for i, v := range values {
128+
asNumber, ok := v.(json.Number)
129+
if !ok {
130+
return attribute.Value{}, ErrUnsupportedSliceValue
131+
}
132+
asInt64, err := asNumber.Int64()
133+
if err != nil {
134+
return attribute.Value{}, err
135+
}
136+
s[i] = asInt64
137+
}
138+
return attribute.Int64SliceValue(s), nil
139+
case bool:
140+
s, err := createTypedSlice[bool](values)
141+
if err != nil {
142+
return attribute.Value{}, err
143+
}
144+
return attribute.BoolSliceValue(s), nil
145+
case string:
146+
s, err := createTypedSlice[string](values)
147+
if err != nil {
148+
return attribute.Value{}, err
149+
}
150+
return attribute.StringSliceValue(s), nil
151+
default:
152+
return attribute.Value{}, ErrUnsupportedValue
153+
}
154+
}
155+
156+
func createTypedSlice[T any](values []any) ([]T, error) {
157+
s := make([]T, len(values))
158+
for i, v := range values {
159+
asT, ok := v.(T)
160+
if !ok {
161+
return nil, ErrUnsupportedSliceValue
162+
}
163+
s[i] = asT
164+
}
165+
return s, nil
166+
}

telemetry/attributes_test.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package telemetry
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
"go.opentelemetry.io/otel/attribute"
10+
)
11+
12+
var attributeTestCases = []struct {
13+
Name string
14+
JSON string
15+
KVs []attribute.KeyValue
16+
}{
17+
{
18+
Name: "bool",
19+
JSON: `{"enabled": true, "is_this_thing_on": false}`,
20+
KVs: []attribute.KeyValue{
21+
attribute.Bool("enabled", true),
22+
attribute.Bool("is_this_thing_on", false),
23+
},
24+
},
25+
{
26+
Name: "int64",
27+
JSON: `{"age": 42, "zero": 0, "bigger_than_int32": 2147483650}`,
28+
KVs: []attribute.KeyValue{
29+
attribute.Int("age", 42),
30+
attribute.Int("zero", 0),
31+
attribute.Int("bigger_than_int32", 2147483650),
32+
},
33+
},
34+
{
35+
Name: "float64",
36+
JSON: `{"pi": 3.141592653589793, "negative": -1.234567}`,
37+
KVs: []attribute.KeyValue{
38+
attribute.Float64("pi", 3.141592653589793),
39+
attribute.Float64("negative", -1.234567),
40+
},
41+
},
42+
{
43+
Name: "string",
44+
JSON: `{"name": "Boz", "empty": ""}`,
45+
KVs: []attribute.KeyValue{
46+
attribute.String("name", "Boz"),
47+
attribute.String("empty", ""),
48+
},
49+
},
50+
{
51+
Name: "bool slice",
52+
JSON: `{"flags": [true, false, false, true]}`,
53+
KVs: []attribute.KeyValue{
54+
attribute.BoolSlice("flags", []bool{true, false, false, true}),
55+
},
56+
},
57+
{
58+
Name: "int64 slice",
59+
JSON: `{"lotto": [12, 17, 46]}`,
60+
KVs: []attribute.KeyValue{
61+
attribute.IntSlice("lotto", []int{12, 17, 46}),
62+
},
63+
},
64+
{
65+
Name: "float64 slice",
66+
JSON: `{"coordinates": [51.477928, -0.001545], "mixed": [1, 2, 3, 4.5]}`,
67+
KVs: []attribute.KeyValue{
68+
attribute.Float64Slice("coordinates", []float64{51.477928, -0.001545}),
69+
attribute.Float64Slice("mixed", []float64{1, 2, 3, 4.5}),
70+
},
71+
},
72+
{
73+
Name: "string slice",
74+
JSON: `{"hobbies": ["gardening", "fishing"], "empty": []}`,
75+
KVs: []attribute.KeyValue{
76+
attribute.StringSlice("hobbies", []string{"gardening", "fishing"}),
77+
attribute.StringSlice("empty", []string{}),
78+
},
79+
},
80+
}
81+
82+
func TestAttributesMarshalFunctional(t *testing.T) {
83+
x := struct {
84+
Attributes Attributes `json:"my_attrs"`
85+
}{
86+
Attributes: Attributes([]attribute.KeyValue{
87+
attribute.Bool("enabled", true),
88+
attribute.Int("age", 42),
89+
attribute.Float64("pi", 3.141592653589793),
90+
attribute.String("name", "Florp"),
91+
}),
92+
}
93+
94+
out, err := json.Marshal(x)
95+
require.NoError(t, err)
96+
97+
assert.JSONEq(t, `{
98+
"my_attrs": {
99+
"enabled": true,
100+
"age": 42,
101+
"pi": 3.141592653589793,
102+
"name": "Florp"
103+
}
104+
}`, string(out))
105+
}
106+
107+
func TestAttributesMarshal(t *testing.T) {
108+
for _, tc := range attributeTestCases {
109+
t.Run(tc.Name, func(t *testing.T) {
110+
out, err := json.Marshal(Attributes(tc.KVs))
111+
require.NoError(t, err)
112+
113+
assert.JSONEq(t, tc.JSON, string(out))
114+
})
115+
}
116+
}
117+
118+
func TestAttributesUnmarshal(t *testing.T) {
119+
for _, tc := range attributeTestCases {
120+
t.Run(tc.Name, func(t *testing.T) {
121+
var attrs Attributes
122+
123+
err := json.Unmarshal([]byte(tc.JSON), &attrs)
124+
require.NoError(t, err)
125+
126+
assert.ElementsMatch(t, tc.KVs, attrs)
127+
})
128+
}
129+
}
130+
131+
// For now, we want to ignore rather than choke on invalid types.
132+
func TestAttributesUnmarshalInvalidTypes(t *testing.T) {
133+
testCases := []struct {
134+
Name string
135+
JSON string
136+
}{
137+
{
138+
Name: "null",
139+
JSON: `{"null": null}`,
140+
},
141+
{
142+
Name: "empty map",
143+
JSON: `{"map": {}}`,
144+
},
145+
{
146+
Name: "map with entries",
147+
JSON: `{"map": {"name": "Chigozie"}}`,
148+
},
149+
{
150+
Name: "mixed type slice",
151+
JSON: `{"mixedup": [123, "eatmyshorts"]}`,
152+
},
153+
{
154+
Name: "nested slice",
155+
JSON: `{"nested": [[1], [2], [3]]}`,
156+
},
157+
}
158+
159+
for _, tc := range testCases {
160+
t.Run(tc.Name, func(t *testing.T) {
161+
var attrs Attributes
162+
163+
err := json.Unmarshal([]byte(tc.JSON), &attrs)
164+
require.NoError(t, err)
165+
166+
assert.Empty(t, attrs)
167+
})
168+
}
169+
}

0 commit comments

Comments
 (0)