Skip to content

[trace] add Unmarshaler functionality to SpanContext and subfields #6738

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
The package contains semantic conventions from the `v1.31.0` version of the OpenTelemetry Semantic Conventions.
See the [migration documentation](./semconv/v1.31.0/MIGRATION.md) for information on how to upgrade from `go.opentelemetry.io/otel/semconv/v1.30.0`(#6479)
- Add `Recording`, `Scope`, and `Record` types in `go.opentelemetry.io/otel/log/logtest`. (#6507)
- Add explicit JSON Unmarshaler interface methods for `trace.SpanContext` objects and subfields in `go.opentelemetry.io/otel/trace` (#6738).

### Removed

Expand Down
93 changes: 87 additions & 6 deletions trace/trace.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,20 @@ type TraceID [16]byte

var (
nilTraceID TraceID
_ json.Marshaler = nilTraceID
_ json.Marshaler = nilTraceID
_ json.Unmarshaler = &nilTraceID

nilSpanID SpanID
_ json.Marshaler = nilSpanID
_ json.Unmarshaler = &nilSpanID

nilTraceFlags TraceFlags
_ json.Marshaler = nilTraceFlags
_ json.Unmarshaler = &nilTraceFlags

nilSpanContext SpanContext
_ json.Marshaler = nilSpanContext
_ json.Unmarshaler = &nilSpanContext
)

// IsValid checks whether the trace TraceID is valid. A valid trace ID does
Expand All @@ -55,14 +68,30 @@ func (t TraceID) String() string {
return hex.EncodeToString(t[:])
}

// UnmarshalJSON implements the json.Unmarshaler interface for TraceID.
// It expects the JSON to be a string (as produced by MarshalJSON), parses
// it via TraceIDFromHex, and replaces the receiver's contents.
func (t *TraceID) UnmarshalJSON(data []byte) error {
// 1) Unmarshal the JSON payload into a Go string.
var raw string
if err := json.Unmarshal(data, &raw); err != nil {
return err
}

// 2) Parse that string into a TraceID.
parsed, err := TraceIDFromHex(raw)
if err != nil {
return err
}

// 3) Overwrite the receiver with the parsed result.
*t = parsed
return nil
}

// SpanID is a unique identity of a span in a trace.
type SpanID [8]byte

var (
nilSpanID SpanID
_ json.Marshaler = nilSpanID
)

// IsValid checks whether the SpanID is valid. A valid SpanID does not consist
// of zeros only.
func (s SpanID) IsValid() bool {
Expand All @@ -80,6 +109,27 @@ func (s SpanID) String() string {
return hex.EncodeToString(s[:])
}

// UnmarshalJSON implements the json.Unmarshaler interface for SpanID.
// It expects the JSON to be a string (as produced by MarshalJSON), parses
// it via SpanIDFromHex, and replaces the receiver's contents.
func (s *SpanID) UnmarshalJSON(data []byte) error {
// 1) Unmarshal the JSON payload into a Go string.
var raw string
if err := json.Unmarshal(data, &raw); err != nil {
return err
}

// 2) Parse that string into a SpanID.
parsed, err := SpanIDFromHex(raw)
if err != nil {
return err
}

// 3) Overwrite the receiver with the parsed result.
*s = parsed
return nil
}

// TraceIDFromHex returns a TraceID from a hex string if it is compliant with
// the W3C trace-context specification. See more at
// https://www.w3.org/TR/trace-context/#trace-id
Expand Down Expand Up @@ -168,6 +218,25 @@ func (tf TraceFlags) String() string {
return hex.EncodeToString([]byte{byte(tf)}[:])
}

// UnmarshalJSON implements the json.Unmarshaler interface for TraceFlags.
// It expects the JSON to be a hex string (as produced by MarshalJSON), parses
// it and sets the value.
func (tf *TraceFlags) UnmarshalJSON(data []byte) error {
var raw string
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
decoded, err := hex.DecodeString(raw)
if err != nil {
return err
}
if len(decoded) != 1 {
return errInvalidTraceIDLength // Use a relevant error or define a new one for TraceFlags
}
*tf = TraceFlags(decoded[0])
return nil
}

// SpanContextConfig contains mutable fields usable for constructing
// an immutable SpanContext.
type SpanContextConfig struct {
Expand Down Expand Up @@ -321,3 +390,15 @@ func (sc SpanContext) MarshalJSON() ([]byte, error) {
Remote: sc.remote,
})
}

// UnmarshalJSON implements the json.Unmarshaler interface for SpanContext.
// It expects the JSON to be an object with fields matching SpanContextConfig,
// and uses NewSpanContext to construct the immutable SpanContext value.
func (sc *SpanContext) UnmarshalJSON(data []byte) error {
var cfg SpanContextConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return err
}
*sc = NewSpanContext(cfg)
return nil
}
251 changes: 251 additions & 0 deletions trace/trace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package trace

import (
"bytes"
"encoding/json"
"testing"

"github.com/google/go-cmp/cmp"
Expand Down Expand Up @@ -571,3 +572,253 @@ func TestConfigLinkMutability(t *testing.T) {
want := SpanConfig{links: []Link{l0, l1}}
assert.Equal(t, want, conf)
}

func TestTraceIDUnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
want TraceID
wantErr bool
}{
{
name: "valid TraceID",
input: `"80f198ee56343ba864fe8b2a57d3eff7"`,
want: TraceID{
0x80,
0xf1,
0x98,
0xee,
0x56,
0x34,
0x3b,
0xa8,
0x64,
0xfe,
0x8b,
0x2a,
0x57,
0xd3,
0xef,
0xf7,
},
},
{
name: "invalid length",
input: `"80f198ee56343ba864fe8b2a57d3eff"`,
wantErr: true,
},
{
name: "invalid char",
input: `"80f198ee56343ba864fe8b2a57d3efg7"`,
wantErr: true,
},
{
name: "all zeros",
input: `"00000000000000000000000000000000"`,
wantErr: true,
},
{
name: "not a string",
input: `123`,
wantErr: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var tid TraceID
err := json.Unmarshal([]byte(tc.input), &tid)
if tc.wantErr {
if err == nil {
t.Errorf("expected error, got nil")
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if tid != tc.want {
t.Errorf("got %v, want %v", tid, tc.want)
}
}
})
}
}

func TestSpanIDUnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
want SpanID
wantErr bool
}{
{
name: "valid SpanID",
input: `"2a00000000000000"`,
want: SpanID{0x2a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
},
{
name: "invalid length",
input: `"2a0000000000000"`,
wantErr: true,
},
{
name: "invalid char",
input: `"2a0000000000000g"`,
wantErr: true,
},
{
name: "all zeros",
input: `"0000000000000000"`,
wantErr: true,
},
{
name: "not a string",
input: `123`,
wantErr: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var sid SpanID
err := json.Unmarshal([]byte(tc.input), &sid)
if tc.wantErr {
if err == nil {
t.Errorf("expected error, got nil")
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if sid != tc.want {
t.Errorf("got %v, want %v", sid, tc.want)
}
}
})
}
}

func TestTraceFlagsUnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
want TraceFlags
wantErr bool
}{
{
name: "valid TraceFlags 01",
input: `"01"`,
want: TraceFlags(0x01),
},
{
name: "valid TraceFlags 00",
input: `"00"`,
want: TraceFlags(0x00),
},
{
name: "invalid hex",
input: `"gg"`,
wantErr: true,
},
{
name: "invalid length",
input: `"0102"`,
wantErr: true,
},
{
name: "not a string",
input: `123`,
wantErr: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var tf TraceFlags
err := json.Unmarshal([]byte(tc.input), &tf)
if tc.wantErr {
if err == nil {
t.Errorf("expected error, got nil")
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if tf != tc.want {
t.Errorf("got %v, want %v", tf, tc.want)
}
}
})
}
}

func TestSpanContextUnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
want SpanContext
wantErr bool
}{
{
name: "valid full SpanContext",
input: `{"TraceID":"01000000000000000000000000000000","SpanID":"2a00000000000000","TraceFlags":"01","TraceState":"foo=1","Remote":true}`,
want: NewSpanContext(SpanContextConfig{
TraceID: TraceID{0x01},
SpanID: SpanID{0x2a},
TraceFlags: TraceFlags(0x01),
TraceState: TraceState{list: []member{{Key: "foo", Value: "1"}}},
Remote: true,
}),
},
{
name: "valid partial SpanContext",
input: `{"TraceID":"01000000000000000000000000000000","SpanID":"2a00000000000000"}`,
want: NewSpanContext(SpanContextConfig{
TraceID: TraceID{0x01},
SpanID: SpanID{0x2a},
}),
},
{
name: "invalid TraceID",
input: `{"TraceID":"00000000000000000000000000000000","SpanID":"2a00000000000000"}`,
wantErr: true,
},
{
name: "invalid JSON",
input: `{"TraceID":123}`,
wantErr: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var sc SpanContext
err := json.Unmarshal([]byte(tc.input), &sc)
if tc.wantErr {
if err == nil {
t.Errorf("expected error, got nil")
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !assertSpanContextEqual(sc, tc.want) {
t.Errorf("got %+v, want %+v", sc, tc.want)
}
// Round-trip test
data, err := json.Marshal(sc)
if err != nil {
t.Errorf("MarshalJSON failed: %v", err)
}
var sc2 SpanContext
err = json.Unmarshal(data, &sc2)
if err != nil {
t.Errorf("UnmarshalJSON round-trip failed: %v", err)
}
if !assertSpanContextEqual(sc, sc2) {
t.Errorf("round-trip: got %+v, want %+v", sc2, sc)
}
}
})
}
}
Loading
Loading