Repository: getsentry/sentry-go

Issue:
User struct is missing fields
The sentry-go User struct only provides 4 fields:  id, username, email, ipaddress
See https://github.com/getsentry/sentry-go/blob/master/interfaces.go#L95

However, the actual Sentry User object contains 4 additional fields:  geo, name, segment, data
See https://develop.sentry.dev/sdk/event-payloads/types/#typedef-User
And https://github.com/getsentry/sentry-data-schemas/blob/main/relay/event.schema.json#L3025

We require the last field in particular (data) since it allows the tracking of arbitrary user data.

Discussion:

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83fa381..f51b34b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
 # Changelog
 
+- feat: Extend User inteface by adding Data, Name and Segment (#483)
+
 ## 0.14.0
 
 - feat: Add function to continue from trace string (#434)
diff --git a/http/sentryhttp.go b/http/sentryhttp.go
index a41b04a..86c5df3 100644
--- a/http/sentryhttp.go
+++ b/http/sentryhttp.go
@@ -86,15 +86,21 @@ func (h *Handler) handle(handler http.Handler) http.HandlerFunc {
 			hub = sentry.CurrentHub().Clone()
 			ctx = sentry.SetHubOnContext(ctx, hub)
 		}
-		span := sentry.StartSpan(ctx, "http.server",
-			sentry.TransactionName(fmt.Sprintf("%s %s", r.Method, r.URL.Path)),
+		options := []sentry.SpanOption{
+			sentry.OpName("http.server"),
 			sentry.ContinueFromRequest(r),
+		}
+		// We don't mind getting an existing transaction back so we don't need to
+		// check if it is.
+		transaction := sentry.StartTransaction(ctx,
+			fmt.Sprintf("%s %s", r.Method, r.URL.Path),
+			options...,
 		)
-		defer span.Finish()
+		defer transaction.Finish()
 		// TODO(tracing): if the next handler.ServeHTTP panics, store
 		// information on the transaction accordingly (status, tag,
 		// level?, ...).
-		r = r.WithContext(span.Context())
+		r = r.WithContext(transaction.Context())
 		hub.Scope().SetRequest(r)
 		defer h.recoverWithSentry(hub, r)
 		// TODO(tracing): use custom response writer to intercept
diff --git a/interfaces.go b/interfaces.go
index a825020..e1b8b46 100644
--- a/interfaces.go
+++ b/interfaces.go
@@ -93,10 +93,45 @@ func (b *Breadcrumb) MarshalJSON() ([]byte, error) {
 // User describes the user associated with an Event. If this is used, at least
 // an ID or an IP address should be provided.
 type User struct {
-	Email     string `json:"email,omitempty"`
-	ID        string `json:"id,omitempty"`
-	IPAddress string `json:"ip_address,omitempty"`
-	Username  string `json:"username,omitempty"`
+	ID        string            `json:"id,omitempty"`
+	Email     string            `json:"email,omitempty"`
+	IPAddress string            `json:"ip_address,omitempty"`
+	Username  string            `json:"username,omitempty"`
+	Name      string            `json:"name,omitempty"`
+	Segment   string            `json:"segment,omitempty"`
+	Data      map[string]string `json:"data,omitempty"`
+}
+
+func (u User) IsEmpty() bool {
+	if len(u.ID) > 0 {
+		return false
+	}
+
+	if len(u.Email) > 0 {
+		return false
+	}
+
+	if len(u.IPAddress) > 0 {
+		return false
+	}
+
+	if len(u.Username) > 0 {
+		return false
+	}
+
+	if len(u.Name) > 0 {
+		return false
+	}
+
+	if len(u.Segment) > 0 {
+		return false
+	}
+
+	if len(u.Data) > 0 {
+		return false
+	}
+
+	return true
 }
 
 // Request contains information on a HTTP request related to the event.
diff --git a/scope.go b/scope.go
index 51dd7fd..228de26 100644
--- a/scope.go
+++ b/scope.go
@@ -379,8 +379,7 @@ func (scope *Scope) ApplyToEvent(event *Event, hint *EventHint) *Event {
 		}
 	}
 
-	var emptyUser User
-	if event.User == emptyUser {
+	if event.User.IsEmpty() {
 		event.User = scope.user
 	}
 
diff --git a/tracing.go b/tracing.go
index 6c992fa..77666d4 100644
--- a/tracing.go
+++ b/tracing.go
@@ -567,6 +567,13 @@ func TransactionName(name string) SpanOption {
 	}
 }
 
+// OpName sets the operation name for a given span.
+func OpName(name string) SpanOption {
+	return func(s *Span) {
+		s.Op = name
+	}
+}
+
 // ContinueFromRequest returns a span option that updates the span to continue
 // an existing trace. If it cannot detect an existing trace in the request, the
 // span will be left unchanged.
@@ -626,3 +633,23 @@ func spanFromContext(ctx context.Context) *Span {
 	}
 	return nil
 }
+
+// StartTransaction will create a transaction (root span) if there's no existing
+// transaction in the context otherwise, it will return the existing transaction.
+func StartTransaction(ctx context.Context, name string, options ...SpanOption) *Span {
+	currentTransaction, exists := ctx.Value(spanContextKey{}).(*Span)
+	if exists {
+		return currentTransaction
+	}
+	hub := GetHubFromContext(ctx)
+	if hub == nil {
+		hub = CurrentHub().Clone()
+		ctx = SetHubOnContext(ctx, hub)
+	}
+	options = append(options, TransactionName(name))
+	return StartSpan(
+		ctx,
+		"",
+		options...,
+	)
+}



Test patch:
diff --git a/interfaces_test.go b/interfaces_test.go
index 02dff0c..ca48ae8 100644
--- a/interfaces_test.go
+++ b/interfaces_test.go
@@ -19,6 +19,52 @@ var (
 	generate = flag.Bool("gen", false, "generate missing .golden files")
 )
 
+func TestUserIsEmpty(t *testing.T) {
+	tests := []struct {
+		input User
+		want  bool
+	}{
+		{input: User{}, want: true},
+		{input: User{ID: "foo"}, want: false},
+		{input: User{Email: "foo@example.com"}, want: false},
+		{input: User{IPAddress: "127.0.0.1"}, want: false},
+		{input: User{Username: "My Username"}, want: false},
+		{input: User{Name: "My Name"}, want: false},
+		{input: User{Segment: "My Segment"}, want: false},
+		{input: User{Data: map[string]string{"foo": "bar"}}, want: false},
+		{input: User{ID: "foo", Email: "foo@example.com", IPAddress: "127.0.0.1", Username: "My Username", Name: "My Name", Segment: "My Segment", Data: map[string]string{"foo": "bar"}}, want: false},
+	}
+
+	for _, test := range tests {
+		assertEqual(t, test.input.IsEmpty(), test.want)
+	}
+}
+
+func TestUserMarshalJson(t *testing.T) {
+	tests := []struct {
+		input User
+		want  string
+	}{
+		{input: User{}, want: `{}`},
+		{input: User{ID: "foo"}, want: `{"id":"foo"}`},
+		{input: User{Email: "foo@example.com"}, want: `{"email":"foo@example.com"}`},
+		{input: User{IPAddress: "127.0.0.1"}, want: `{"ip_address":"127.0.0.1"}`},
+		{input: User{Username: "My Username"}, want: `{"username":"My Username"}`},
+		{input: User{Name: "My Name"}, want: `{"name":"My Name"}`},
+		{input: User{Segment: "My Segment"}, want: `{"segment":"My Segment"}`},
+		{input: User{Data: map[string]string{"foo": "bar"}}, want: `{"data":{"foo":"bar"}}`},
+	}
+
+	for _, test := range tests {
+		got, err := json.Marshal(test.input)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertEqual(t, string(got), test.want)
+	}
+}
+
 func TestNewRequest(t *testing.T) {
 	const payload = `{"test_data": true}`
 	got := NewRequest(httptest.NewRequest("POST", "/test/?q=sentry", strings.NewReader(payload)))
diff --git a/scope_test.go b/scope_test.go
index b7fb8c3..3b9fc6d 100644
--- a/scope_test.go
+++ b/scope_test.go
@@ -37,8 +37,9 @@ func fillEventWithData(event *Event) *Event {
 
 func TestScopeSetUser(t *testing.T) {
 	scope := NewScope()
-	scope.SetUser(User{ID: "foo"})
-	assertEqual(t, User{ID: "foo"}, scope.user)
+	scope.SetUser(User{ID: "foo", Email: "foo@example.com", IPAddress: "127.0.0.1", Username: "My Username", Name: "My Name", Segment: "My Segment", Data: map[string]string{"foo": "bar"}})
+
+	assertEqual(t, User{ID: "foo", Email: "foo@example.com", IPAddress: "127.0.0.1", Username: "My Username", Name: "My Name", Segment: "My Segment", Data: map[string]string{"foo": "bar"}}, scope.user)
 }
 
 func TestScopeSetUserOverrides(t *testing.T) {
diff --git a/tracing_test.go b/tracing_test.go
index 69819da..347a6dd 100644
--- a/tracing_test.go
+++ b/tracing_test.go
@@ -226,6 +226,78 @@ func TestStartChild(t *testing.T) {
 	}
 }
 
+func TestStartTransaction(t *testing.T) {
+	transport := &TransportMock{}
+	ctx := NewTestContext(ClientOptions{
+		Transport: transport,
+	})
+	transactionName := "Test Transaction"
+	description := "A Description"
+	status := SpanStatusOK
+	sampled := SampledTrue
+	startTime := time.Now()
+	endTime := startTime.Add(3 * time.Second)
+	data := map[string]interface{}{
+		"k": "v",
+	}
+	transaction := StartTransaction(ctx,
+		transactionName,
+		func(s *Span) {
+			s.Description = description
+			s.Status = status
+			s.Sampled = sampled
+			s.StartTime = startTime
+			s.EndTime = endTime
+			s.Data = data
+		},
+	)
+	transaction.Finish()
+
+	SpanCheck{
+		Sampled:     sampled,
+		RecorderLen: 1,
+	}.Check(t, transaction)
+
+	events := transport.Events()
+	if got := len(events); got != 1 {
+		t.Fatalf("sent %d events, want 1", got)
+	}
+	want := &Event{
+		Type:        transactionType,
+		Transaction: transactionName,
+		Contexts: map[string]Context{
+			"trace": TraceContext{
+				TraceID:     transaction.TraceID,
+				SpanID:      transaction.SpanID,
+				Description: description,
+				Status:      status,
+			}.Map(),
+		},
+		Tags: nil,
+		// TODO(tracing): the root span / transaction data field is
+		// mapped into Event.Extra for now, pending spec clarification.
+		// https://github.com/getsentry/develop/issues/244#issuecomment-778694182
+		Extra:     transaction.Data,
+		Timestamp: endTime,
+		StartTime: startTime,
+	}
+	opts := cmp.Options{
+		cmpopts.IgnoreFields(Event{},
+			"Contexts", "EventID", "Level", "Platform",
+			"Release", "Sdk", "ServerName", "Modules",
+		),
+		cmpopts.EquateEmpty(),
+	}
+	if diff := cmp.Diff(want, events[0], opts); diff != "" {
+		t.Fatalf("Event mismatch (-want +got):\n%s", diff)
+	}
+	// Check trace context explicitly, as we ignored all contexts above to
+	// disregard other contexts.
+	if diff := cmp.Diff(want.Contexts["trace"], events[0].Contexts["trace"]); diff != "" {
+		t.Fatalf("TraceContext mismatch (-want +got):\n%s", diff)
+	}
+}
+
 // testContextKey is used to store a value in a context so that we can check
 // that SDK operations on that context preserve the original context values.
 type testContextKey struct{}