diff --git a/interfaces.go b/interfaces.go index 0e7baa58e..6e7c7c9bb 100644 --- a/interfaces.go +++ b/interfaces.go @@ -186,13 +186,19 @@ type Event struct { Request *Request `json:"request,omitempty"` Exception []Exception `json:"exception,omitempty"` - // Experimental: This is part of a beta feature of the SDK. The fields below - // are only relevant for transactions. - Type string `json:"type,omitempty"` - StartTimestamp time.Time `json:"start_timestamp"` - Spans []*Span `json:"spans,omitempty"` + // The fields below are only relevant for transactions. + + Type string `json:"type,omitempty"` + StartTime time.Time `json:"start_timestamp"` + Spans []*Span `json:"spans,omitempty"` } +// TODO: Event.Contexts map[string]interface{} => map[string]EventContext, +// to prevent accidentally storing T when we mean *T. +// For example, the TraceContext must be stored as *TraceContext to pick up the +// MarshalJSON method (and avoid copying). +// type EventContext interface{ EventContext() } + // MarshalJSON converts the Event struct to JSON. func (e *Event) MarshalJSON() ([]byte, error) { // We want to omit time.Time zero values, otherwise the server will try to @@ -226,9 +232,9 @@ func (e *Event) defaultMarshalJSON() ([]byte, error) { // be sent for transactions. They shadow the respective fields in Event // and are meant to remain nil, triggering the omitempty behavior. - Type json.RawMessage `json:"type,omitempty"` - StartTimestamp json.RawMessage `json:"start_timestamp,omitempty"` - Spans json.RawMessage `json:"spans,omitempty"` + Type json.RawMessage `json:"type,omitempty"` + StartTime json.RawMessage `json:"start_timestamp,omitempty"` + Spans json.RawMessage `json:"spans,omitempty"` } x := errorEvent{event: (*event)(e)} @@ -255,8 +261,8 @@ func (e *Event) transactionMarshalJSON() ([]byte, error) { // The fields below shadow the respective fields in Event. They allow us // to include timestamps when non-zero and omit them otherwise. - StartTimestamp json.RawMessage `json:"start_timestamp,omitempty"` - Timestamp json.RawMessage `json:"timestamp,omitempty"` + StartTime json.RawMessage `json:"start_timestamp,omitempty"` + Timestamp json.RawMessage `json:"timestamp,omitempty"` } x := transactionEvent{event: (*event)(e)} @@ -267,12 +273,12 @@ func (e *Event) transactionMarshalJSON() ([]byte, error) { } x.Timestamp = b } - if !e.StartTimestamp.IsZero() { - b, err := e.StartTimestamp.MarshalJSON() + if !e.StartTime.IsZero() { + b, err := e.StartTime.MarshalJSON() if err != nil { return nil, err } - x.StartTimestamp = b + x.StartTime = b } return json.Marshal(x) } @@ -307,30 +313,3 @@ type EventHint struct { Request *http.Request Response *http.Response } - -// TraceContext describes the context of the trace. -// -// Experimental: This is part of a beta feature of the SDK. -type TraceContext struct { - TraceID string `json:"trace_id"` - SpanID string `json:"span_id"` - Op string `json:"op,omitempty"` - Description string `json:"description,omitempty"` - Status string `json:"status,omitempty"` -} - -// Span describes a timed unit of work in a trace. -// -// Experimental: This is part of a beta feature of the SDK. -type Span struct { - TraceID string `json:"trace_id"` - SpanID string `json:"span_id"` - ParentSpanID string `json:"parent_span_id,omitempty"` - Op string `json:"op,omitempty"` - Description string `json:"description,omitempty"` - Status string `json:"status,omitempty"` - Tags map[string]string `json:"tags,omitempty"` - StartTimestamp time.Time `json:"start_timestamp"` - EndTimestamp time.Time `json:"timestamp"` - Data map[string]interface{} `json:"data,omitempty"` -} diff --git a/interfaces_test.go b/interfaces_test.go index 99e7b5ce2..b564c65ca 100644 --- a/interfaces_test.go +++ b/interfaces_test.go @@ -44,14 +44,14 @@ func TestNewRequest(t *testing.T) { func TestEventMarshalJSON(t *testing.T) { event := NewEvent() event.Spans = []*Span{{ - TraceID: "d6c4f03650bd47699ec65c84352b6208", - SpanID: "1cc4b26ab9094ef0", - ParentSpanID: "442bd97bbe564317", - StartTimestamp: time.Unix(8, 0).UTC(), - EndTimestamp: time.Unix(10, 0).UTC(), - Status: "ok", + TraceID: TraceIDFromHex("d6c4f03650bd47699ec65c84352b6208"), + SpanID: SpanIDFromHex("1cc4b26ab9094ef0"), + ParentSpanID: SpanIDFromHex("442bd97bbe564317"), + StartTime: time.Unix(8, 0).UTC(), + EndTime: time.Unix(10, 0).UTC(), + Status: SpanStatusOK, }} - event.StartTimestamp = time.Unix(7, 0).UTC() + event.StartTime = time.Unix(7, 0).UTC() event.Timestamp = time.Unix(14, 0).UTC() got, err := json.Marshal(event) @@ -59,7 +59,7 @@ func TestEventMarshalJSON(t *testing.T) { t.Fatal(err) } - // Non transaction event should not have fields Spans and StartTimestamp + // Non-transaction event should not have fields Spans and StartTime want := `{"sdk":{},"user":{},"timestamp":"1970-01-01T00:00:14Z"}` if diff := cmp.Diff(want, string(got)); diff != "" { @@ -69,18 +69,18 @@ func TestEventMarshalJSON(t *testing.T) { func TestStructSnapshots(t *testing.T) { testSpan := &Span{ - TraceID: "d6c4f03650bd47699ec65c84352b6208", - SpanID: "1cc4b26ab9094ef0", - ParentSpanID: "442bd97bbe564317", + TraceID: TraceIDFromHex("d6c4f03650bd47699ec65c84352b6208"), + SpanID: SpanIDFromHex("1cc4b26ab9094ef0"), + ParentSpanID: SpanIDFromHex("442bd97bbe564317"), Description: `SELECT * FROM user WHERE "user"."id" = {id}`, Op: "db.sql", Tags: map[string]string{ "function_name": "get_users", "status_message": "MYSQL OK", }, - StartTimestamp: time.Unix(0, 0).UTC(), - EndTimestamp: time.Unix(5, 0).UTC(), - Status: "ok", + StartTime: time.Unix(0, 0).UTC(), + EndTime: time.Unix(5, 0).UTC(), + Status: SpanStatusOK, Data: map[string]interface{}{ "related_ids": []uint{12312342, 76572, 4123485}, "aws_instance": "ca-central-1", @@ -134,17 +134,17 @@ func TestStructSnapshots(t *testing.T) { { testName: "transaction_event", sentryStruct: &Event{ - Type: transactionType, - Spans: []*Span{testSpan}, - StartTimestamp: time.Unix(3, 0).UTC(), - Timestamp: time.Unix(5, 0).UTC(), + Type: transactionType, + Spans: []*Span{testSpan}, + StartTime: time.Unix(3, 0).UTC(), + Timestamp: time.Unix(5, 0).UTC(), Contexts: map[string]interface{}{ - "trace": TraceContext{ - TraceID: "90d57511038845dcb4164a70fc3a7fdb", - SpanID: "f7f3fd754a9040eb", + "trace": &TraceContext{ + TraceID: TraceIDFromHex("90d57511038845dcb4164a70fc3a7fdb"), + SpanID: SpanIDFromHex("f7f3fd754a9040eb"), Op: "http.GET", Description: "description", - Status: "ok", + Status: SpanStatusOK, }, }, }, diff --git a/marshal_test.go b/marshal_test.go index 936ac79ef..25e70389e 100644 --- a/marshal_test.go +++ b/marshal_test.go @@ -89,14 +89,14 @@ func TestErrorEventMarshalJSON(t *testing.T) { func TestTransactionEventMarshalJSON(t *testing.T) { tests := []*Event{ { - Type: transactionType, - StartTimestamp: goReleaseDate.Add(-time.Minute), - Timestamp: goReleaseDate, + Type: transactionType, + StartTime: goReleaseDate.Add(-time.Minute), + Timestamp: goReleaseDate, }, { - Type: transactionType, - StartTimestamp: goReleaseDate.Add(-time.Minute).In(utcMinusTwo), - Timestamp: goReleaseDate.In(utcMinusTwo), + Type: transactionType, + StartTime: goReleaseDate.Add(-time.Minute).In(utcMinusTwo), + Timestamp: goReleaseDate.In(utcMinusTwo), }, { Type: transactionType, diff --git a/testdata/span.golden b/testdata/span.golden index 31730e885..fda30d92a 100644 --- a/testdata/span.golden +++ b/testdata/span.golden @@ -1,7 +1,6 @@ { "trace_id": "d6c4f03650bd47699ec65c84352b6208", "span_id": "1cc4b26ab9094ef0", - "parent_span_id": "442bd97bbe564317", "op": "db.sql", "description": "SELECT * FROM user WHERE \"user\".\"id\" = {id}", "status": "ok", @@ -18,5 +17,6 @@ 76572, 4123485 ] - } + }, + "parent_span_id": "442bd97bbe564317" } \ No newline at end of file diff --git a/testdata/transaction_event.golden b/testdata/transaction_event.golden index 63181432f..7468fdb33 100644 --- a/testdata/transaction_event.golden +++ b/testdata/transaction_event.golden @@ -15,7 +15,6 @@ { "trace_id": "d6c4f03650bd47699ec65c84352b6208", "span_id": "1cc4b26ab9094ef0", - "parent_span_id": "442bd97bbe564317", "op": "db.sql", "description": "SELECT * FROM user WHERE \"user\".\"id\" = {id}", "status": "ok", @@ -32,7 +31,8 @@ 76572, 4123485 ] - } + }, + "parent_span_id": "442bd97bbe564317" } ], "start_timestamp": "1970-01-01T00:00:03Z", diff --git a/tracing.go b/tracing.go new file mode 100644 index 000000000..b59acd662 --- /dev/null +++ b/tracing.go @@ -0,0 +1,192 @@ +package sentry + +import ( + "encoding/hex" + "encoding/json" + "time" +) + +// A Span is the building block of a Sentry transaction. Spans build up a tree +// structure of timed operations. The span tree makes up a transaction event +// that is sent to Sentry when the root span is finished. +type Span struct { + TraceID TraceID `json:"trace_id"` + SpanID SpanID `json:"span_id"` + ParentSpanID SpanID `json:"parent_span_id"` + Op string `json:"op,omitempty"` + Description string `json:"description,omitempty"` + Status SpanStatus `json:"status,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + StartTime time.Time `json:"start_timestamp"` + EndTime time.Time `json:"timestamp"` + Data map[string]interface{} `json:"data,omitempty"` +} + +// TODO: make Span.Tags and Span.Data opaque types (struct{unexported []slice}). +// An opaque type allows us to add methods and make it more convenient to use +// than maps, because maps require careful nil checks to use properly or rely on +// explicit initialization for every span, even when there might be no +// tags/data. For Span.Data, must gracefully handle values that cannot be +// marshaled into JSON (see transport.go:getRequestBodyFromEvent). + +func (s *Span) MarshalJSON() ([]byte, error) { + // span aliases Span to allow calling json.Marshal without an infinite loop. + // It preserves all fields while none of the attached methods. + type span Span + var parentSpanID string + if s.ParentSpanID != zeroSpanID { + parentSpanID = s.ParentSpanID.String() + } + return json.Marshal(struct { + *span + ParentSpanID string `json:"parent_span_id,omitempty"` + }{ + span: (*span)(s), + ParentSpanID: parentSpanID, + }) +} + +// TraceID identifies a trace. +type TraceID [16]byte + +func (id TraceID) Hex() []byte { + b := make([]byte, hex.EncodedLen(len(id))) + hex.Encode(b, id[:]) + return b +} + +func (id TraceID) String() string { + return string(id.Hex()) +} + +func (id TraceID) MarshalText() ([]byte, error) { + return id.Hex(), nil +} + +// SpanID identifies a span. +type SpanID [8]byte + +func (id SpanID) Hex() []byte { + b := make([]byte, hex.EncodedLen(len(id))) + hex.Encode(b, id[:]) + return b +} + +func (id SpanID) String() string { + return string(id.Hex()) +} + +func (id SpanID) MarshalText() ([]byte, error) { + return id.Hex(), nil +} + +// Zero values of TraceID and SpanID used for comparisons. +var ( + //nolint // zeroTraceID TraceID + zeroSpanID SpanID +) + +// SpanStatus is the status of a span. +type SpanStatus uint8 + +// Implementation note: +// +// In Relay (ingestion), the SpanStatus type is an enum used as +// Annotated when embedded in structs, making it effectively +// Option. It means the status is either null or one of the known +// string values. +// +// In Snuba (search), the SpanStatus is stored as an uint8 and defaulted to 2 +// ("unknown") when not set. It means that Discover searches for +// `transaction.status:unknown` return both transactions/spans with status +// `null` or `"unknown"`. Searches for `transaction.status:""` return nothing. +// +// With that in mind, the Go SDK default is SpanStatusUndefined, which is +// null/omitted when serializing to JSON, but integrations may update the status +// automatically based on contextual information. + +const ( + SpanStatusUndefined SpanStatus = iota + SpanStatusOK + SpanStatusCanceled + SpanStatusUnknown + SpanStatusInvalidArgument + SpanStatusDeadlineExceeded + SpanStatusNotFound + SpanStatusAlreadyExists + SpanStatusPermissionDenied + SpanStatusResourceExhausted + SpanStatusFailedPrecondition + SpanStatusAborted + SpanStatusOutOfRange + SpanStatusUnimplemented + SpanStatusInternalError + SpanStatusUnavailable + SpanStatusDataLoss + SpanStatusUnauthenticated + maxSpanStatus +) + +func (ss SpanStatus) String() string { + if ss >= maxSpanStatus { + return "" + } + m := [maxSpanStatus]string{ + "", + "ok", + "cancelled", // [sic] + "unknown", + "invalid_argument", + "deadline_exceeded", + "not_found", + "already_exists", + "permission_denied", + "resource_exhausted", + "failed_precondition", + "aborted", + "out_of_range", + "unimplemented", + "internal_error", + "unavailable", + "data_loss", + "unauthenticated", + } + return m[ss] +} + +func (ss SpanStatus) MarshalJSON() ([]byte, error) { + s := ss.String() + if s == "" { + return []byte("null"), nil + } + return json.Marshal(s) +} + +// A TraceContext carries information about an ongoing trace and is meant to be +// stored in Event.Contexts (as *TraceContext). +type TraceContext struct { + TraceID TraceID `json:"trace_id"` + SpanID SpanID `json:"span_id"` + ParentSpanID SpanID `json:"parent_span_id"` + Op string `json:"op,omitempty"` + Description string `json:"description,omitempty"` + Status SpanStatus `json:"status,omitempty"` +} + +func (tc *TraceContext) MarshalJSON() ([]byte, error) { + // traceContext aliases TraceContext to allow calling json.Marshal without + // an infinite loop. It preserves all fields while none of the attached + // methods. + type traceContext TraceContext + var parentSpanID string + if tc.ParentSpanID != zeroSpanID { + parentSpanID = tc.ParentSpanID.String() + } + return json.Marshal(struct { + *traceContext + ParentSpanID string `json:"parent_span_id,omitempty"` + }{ + traceContext: (*traceContext)(tc), + ParentSpanID: parentSpanID, + }) +} diff --git a/tracing_test.go b/tracing_test.go new file mode 100644 index 000000000..c82819ef4 --- /dev/null +++ b/tracing_test.go @@ -0,0 +1,81 @@ +package sentry + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "testing" +) + +func TraceIDFromHex(s string) TraceID { + var id TraceID + _, err := hex.Decode(id[:], []byte(s)) + if err != nil { + panic(err) + } + return id +} + +func SpanIDFromHex(s string) SpanID { + var id SpanID + _, err := hex.Decode(id[:], []byte(s)) + if err != nil { + panic(err) + } + return id +} + +func TestSpanMarshalJSON(t *testing.T) { + s := &Span{} + testMarshalJSONOmitEmptyParentSpanID(t, s) +} + +func TestSpanStatusMarshalJSON(t *testing.T) { + tests := map[SpanStatus]string{ + SpanStatus(42): `null`, + SpanStatusUndefined: `null`, + SpanStatusOK: `"ok"`, + SpanStatusDeadlineExceeded: `"deadline_exceeded"`, + SpanStatusCanceled: `"cancelled"`, + } + for s, want := range tests { + s, want := s, want + t.Run(fmt.Sprintf("SpanStatus(%d)", s), func(t *testing.T) { + b, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + got := string(b) + if got != want { + t.Fatalf("got %s, want %s", got, want) + } + }) + } +} + +func TestTraceContextMarshalJSON(t *testing.T) { + tc := &TraceContext{} + testMarshalJSONOmitEmptyParentSpanID(t, tc) +} + +func testMarshalJSONOmitEmptyParentSpanID(t *testing.T, v interface{}) { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(b, []byte("parent_span_id")) { + t.Fatalf("unwanted parent_span_id: %s", b) + } + id := reflect.ValueOf(SpanIDFromHex("c7b73e77a3734fee")) + reflect.ValueOf(v).Elem().FieldByName("ParentSpanID").Set(id) + b, err = json.Marshal(v) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(b, []byte("parent_span_id")) { + t.Fatalf("missing parent_span_id: %s", b) + } +}