Skip to content
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

feat: Add Transactions to sentry-go #235

Merged
merged 19 commits into from
Jun 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion client.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,8 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod
return nil
}

if options.BeforeSend != nil {
// As per spec, transactions do not go through BeforeSend.
if event.Type != transactionType && options.BeforeSend != nil {
h := &EventHint{}
if hint != nil {
h = hint
Expand Down
16 changes: 13 additions & 3 deletions dsn.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,19 @@ func (dsn Dsn) String() string {
return url
}

// StoreAPIURL returns assembled url to be used in the transport.
// It points to configures Sentry instance.
// StoreAPIURL returns the URL of the store endpoint of the project associated
// with the DSN.
func (dsn Dsn) StoreAPIURL() *url.URL {
return dsn.getAPIURL("store")
}

// EnvelopeAPIURL returns the URL of the envelope endpoint of the project
// associated with the DSN.
func (dsn Dsn) EnvelopeAPIURL() *url.URL {
return dsn.getAPIURL("envelope")
}

func (dsn Dsn) getAPIURL(s string) *url.URL {
var rawURL string
rawURL += fmt.Sprintf("%s://%s", dsn.scheme, dsn.host)
if dsn.port != dsn.scheme.defaultPort() {
Expand All @@ -152,7 +162,7 @@ func (dsn Dsn) StoreAPIURL() *url.URL {
if dsn.path != "" {
rawURL += dsn.path
}
rawURL += fmt.Sprintf("/api/%d/store/", dsn.projectID)
rawURL += fmt.Sprintf("/api/%d/%s/", dsn.projectID, s)
parsedURL, _ := url.Parse(rawURL)
return parsedURL
}
Expand Down
21 changes: 15 additions & 6 deletions dsn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import (
)

type DsnTest struct {
in string
dsn *Dsn // expected value after parsing
url string // expected Store API URL
in string
dsn *Dsn // expected value after parsing
url string // expected Store API URL
envURL string // expected Envelope API URL
}

//nolint: gochecknoglobals
Expand All @@ -28,7 +29,8 @@ var dsnTests = map[string]DsnTest{
path: "/foo/bar",
projectID: 42,
},
url: "https://domain:8888/foo/bar/api/42/store/",
url: "https://domain:8888/foo/bar/api/42/store/",
envURL: "https://domain:8888/foo/bar/api/42/envelope/",
},
"MinimalSecure": {
in: "https://public@domain/42",
Expand All @@ -39,7 +41,8 @@ var dsnTests = map[string]DsnTest{
port: 443,
projectID: 42,
},
url: "https://domain/api/42/store/",
url: "https://domain/api/42/store/",
envURL: "https://domain/api/42/envelope/",
},
"MinimalInsecure": {
in: "http://public@domain/42",
Expand All @@ -50,7 +53,8 @@ var dsnTests = map[string]DsnTest{
port: 80,
projectID: 42,
},
url: "http://domain/api/42/store/",
url: "http://domain/api/42/store/",
envURL: "http://domain/api/42/envelope/",
},
}

Expand All @@ -71,6 +75,11 @@ func TestNewDsn(t *testing.T) {
if diff := cmp.Diff(tt.url, url); diff != "" {
t.Errorf("dsn.StoreAPIURL() mismatch (-want +got):\n%s", diff)
}
// Envelope API URL
url = dsn.EnvelopeAPIURL().String()
if diff := cmp.Diff(tt.envURL, url); diff != "" {
t.Errorf("dsn.EnvelopeAPIURL() mismatch (-want +got):\n%s", diff)
}
})
}
}
Expand Down
89 changes: 74 additions & 15 deletions interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import (
// Level marks the severity of the event
type Level string

// transactionType is the type of a transaction event.
const transactionType = "transaction"

const (
LevelDebug Level = "debug"
LevelInfo Level = "info"
Expand Down Expand Up @@ -139,7 +142,7 @@ type Exception struct {

type EventID string

// https://docs.sentry.io/development/sdk-dev/event-payloads/
// Event is the fundamental data structure that is sent to Sentry.
type Event struct {
Breadcrumbs []*Breadcrumb `json:"breadcrumbs,omitempty"`
Contexts map[string]interface{} `json:"contexts,omitempty"`
Expand All @@ -163,24 +166,54 @@ type Event struct {
Modules map[string]string `json:"modules,omitempty"`
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"`
}

// MarshalJSON converts the Event struct to JSON.
func (e *Event) MarshalJSON() ([]byte, error) {
type alias Event
// encoding/json doesn't support the "omitempty" option for struct types.
// See https://golang.org/issues/11939.
// This implementation of MarshalJSON shadows the original Timestamp field
// forcing it to be omitted when the Timestamp is the zero value of
// time.Time.
if e.Timestamp.IsZero() {
return json.Marshal(&struct {
*alias
Timestamp json.RawMessage `json:"timestamp,omitempty"`
}{
alias: (*alias)(e),
})
// event aliases Event to allow calling json.Marshal without an infinite
// loop. It preserves all fields of Event while none of the attached
// methods.
type event Event

// Transactions are marshaled in the standard way how json.Marshal works.
if e.Type == transactionType {
return json.Marshal((*event)(e))
}
return json.Marshal((*alias)(e))

// errorEvent is like Event with some shadowed fields for customizing the
// JSON serialization of regular "error events".
type errorEvent struct {
*event

// encoding/json doesn't support the omitempty option for struct types.
// See https://golang.org/issues/11939.
// We shadow the original Event.Timestamp field with a json.RawMessage.
// This allows us to include the timestamp when non-zero and omit it
// otherwise.
Timestamp json.RawMessage `json:"timestamp,omitempty"`

// The fields below are not part of the regular "error events" and only
// make sense to 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"`
}

x := &errorEvent{event: (*event)(e)}
if !e.Timestamp.IsZero() {
x.Timestamp = append(x.Timestamp, '"')
x.Timestamp = e.Timestamp.UTC().AppendFormat(x.Timestamp, time.RFC3339Nano)
x.Timestamp = append(x.Timestamp, '"')
}
return json.Marshal(x)
}

func NewEvent() *Event {
Expand Down Expand Up @@ -211,3 +244,29 @@ 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"`
}
142 changes: 142 additions & 0 deletions interfaces_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
package sentry

import (
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"

"github.com/google/go-cmp/cmp"
)

var update = flag.Bool("update", false, "update .golden files") //nolint: gochecknoglobals

func TestNewRequest(t *testing.T) {
const payload = `{"test_data": true}`
got := NewRequest(httptest.NewRequest("POST", "/test/?q=sentry", strings.NewReader(payload)))
Expand All @@ -29,3 +37,137 @@ func TestNewRequest(t *testing.T) {
t.Errorf("Request mismatch (-want +got):\n%s", diff)
}
}

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",
}}
event.StartTimestamp = time.Unix(7, 0).UTC()
event.Timestamp = time.Unix(14, 0).UTC()

got, err := json.Marshal(event)
if err != nil {
t.Fatal(err)
}

// Non transaction event should not have fields Spans and StartTimestamp
want := `{"sdk":{},"user":{},"timestamp":"1970-01-01T00:00:14Z"}`

if diff := cmp.Diff(want, string(got)); diff != "" {
t.Errorf("Event mismatch (-want +got):\n%s", diff)
}
}

func TestStructSnapshots(t *testing.T) {
testSpan := &Span{
TraceID: "d6c4f03650bd47699ec65c84352b6208",
SpanID: "1cc4b26ab9094ef0",
ParentSpanID: "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",
}

testCases := []struct {
testName string
sentryStruct interface{}
}{
{
testName: "span",
sentryStruct: testSpan,
},
{
testName: "error_event",
sentryStruct: &Event{
Message: "event message",
Environment: "production",
EventID: EventID("0123456789abcdef"),
Fingerprint: []string{"abcd"},
Level: LevelError,
Platform: "myplatform",
Release: "myrelease",
Sdk: SdkInfo{
Name: "sentry.go",
Version: "0.0.1",
Integrations: []string{"gin", "iris"},
Packages: []SdkPackage{{
Name: "sentry-go",
Version: "0.0.1",
}},
},
ServerName: "myhost",
Timestamp: time.Unix(5, 0).UTC(),
Transaction: "mytransaction",
User: User{ID: "foo"},
Breadcrumbs: []*Breadcrumb{{
Data: map[string]interface{}{
"data_key": "data_val",
},
}},
Extra: map[string]interface{}{
"extra_key": "extra_val",
},
Contexts: map[string]interface{}{
"context_key": "context_val",
},
},
},
{
testName: "transaction_event",
sentryStruct: &Event{
Type: transactionType,
Spans: []*Span{testSpan},
StartTimestamp: time.Unix(3, 0).UTC(),
Timestamp: time.Unix(5, 0).UTC(),
Contexts: map[string]interface{}{
"trace": TraceContext{
TraceID: "90d57511038845dcb4164a70fc3a7fdb",
SpanID: "f7f3fd754a9040eb",
Op: "http.GET",
Description: "description",
Status: "ok",
},
},
},
},
}

for _, test := range testCases {
test := test
t.Run(test.testName, func(t *testing.T) {
got, err := json.MarshalIndent(test.sentryStruct, "", " ")
if err != nil {
t.Error(err)
}

golden := filepath.Join(".", "testdata", fmt.Sprintf("%s.golden", test.testName))
if *update {
err := ioutil.WriteFile(golden, got, 0600)
if err != nil {
t.Fatal(err)
}
}

want, err := ioutil.ReadFile(golden)
if err != nil {
t.Fatal(err)
}

if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("struct %s mismatch (-want +got):\n%s", test.testName, diff)
}
})
}
}
Loading