Skip to content
Open
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
17 changes: 15 additions & 2 deletions statsig-go/statsig.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ type EventPayload struct {
}

type Statsig struct {
ref atomic.Uint64
ref atomic.Uint64
allowNilUserID bool
}

func NewStatsig(sdkKey string) (*Statsig, error) {
Expand All @@ -42,7 +43,7 @@ func NewStatsigWithOptions(sdkKey string, opts *StatsigOptions) (*Statsig, error
return nil, fmt.Errorf("error creating Statsig instance")
}

s := &Statsig{ref: atomic.Uint64{}}
s := &Statsig{ref: atomic.Uint64{}, allowNilUserID: opts.allowNilUserID}
s.ref.Store(ref)

runtime.SetFinalizer(s, func(obj *Statsig) {
Expand All @@ -52,6 +53,18 @@ func NewStatsigWithOptions(sdkKey string, opts *StatsigOptions) (*Statsig, error
return s, nil
}

func (s *Statsig) NewUserBuilderWithUserID(userID string) *StatsigUserBuilder {
b := NewUserBuilderWithUserID(userID)
b.allowNilUserID = s.allowNilUserID
return b
}

func (s *Statsig) NewUserBuilderWithCustomIDs(customIDs map[string]any) *StatsigUserBuilder {
b := NewUserBuilderWithCustomIDs(customIDs)
b.allowNilUserID = s.allowNilUserID
return b
}

func (s *Statsig) Initialize() {
GetFFI().statsig_initialize_blocking(s.ref.Load())
}
Expand Down
13 changes: 11 additions & 2 deletions statsig-go/statsig_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import (
)

type StatsigOptions struct {
ref uint64
ref uint64
allowNilUserID bool
}

type StatsigOptionsBuilder struct {
Expand Down Expand Up @@ -35,6 +36,8 @@ type StatsigOptionsBuilder struct {
PersistentStorageRef *uint64 `json:"persistent_storage_ref,omitempty"`
InitTimeoutMs *int32 `json:"init_timeout_ms,omitempty"`
FallbackToStatsigApi *bool `json:"fallback_to_statsig_api,omitempty"`

AllowNilUserID bool `json:"-"`
}

func NewOptionsBuilder() *StatsigOptionsBuilder {
Expand Down Expand Up @@ -141,6 +144,11 @@ func (o *StatsigOptionsBuilder) WithPersistentStorage(persistentStorage *Persist
return o
}

func (o *StatsigOptionsBuilder) WithAllowNilUserID(allow bool) *StatsigOptionsBuilder {
o.AllowNilUserID = allow
return o
}

func (o *StatsigOptionsBuilder) Build() (*StatsigOptions, error) {
data, err := json.Marshal(o)
if err != nil {
Expand All @@ -156,7 +164,8 @@ func (o *StatsigOptionsBuilder) Build() (*StatsigOptions, error) {
}

options := &StatsigOptions{
ref,
ref: ref,
allowNilUserID: o.AllowNilUserID,
}

runtime.SetFinalizer(options, func(obj *StatsigOptions) {
Expand Down
15 changes: 11 additions & 4 deletions statsig-go/statsig_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ type StatsigUser struct {

// todo: introduce custom type for handling valid JSON primitives only instead of using 'any'
type StatsigUserBuilder struct {
UserID string `json:"userID"`
UserID *string `json:"userID,omitempty"`
// map[string] string | number
CustomIDs map[string]any `json:"customIDs"`
Email *string `json:"email"`
Expand All @@ -25,11 +25,13 @@ type StatsigUserBuilder struct {
Custom *map[string]any `json:"custom"`
// map[string] string | number | boolean | array<string>
PrivateAttributes *map[string]any `json:"privateAttributes"`

allowNilUserID bool
}

func NewUserBuilderWithUserID(userID string) *StatsigUserBuilder {
return &StatsigUserBuilder{
UserID: userID,
UserID: &userID,
}
}

Expand All @@ -40,7 +42,7 @@ func NewUserBuilderWithCustomIDs(customIDs map[string]any) *StatsigUserBuilder {
}

func (b *StatsigUserBuilder) WithUserID(userID string) *StatsigUserBuilder {
b.UserID = userID
b.UserID = &userID
return b
}

Expand Down Expand Up @@ -90,7 +92,12 @@ func (b *StatsigUserBuilder) WithPrivateAttributes(privateAttributes map[string]
}

func (b *StatsigUserBuilder) Build() (*StatsigUser, error) {
jsonData, err := json.Marshal(b)
snapshot := *b
if !b.allowNilUserID && snapshot.UserID == nil {
empty := ""
snapshot.UserID = &empty
}
jsonData, err := json.Marshal(&snapshot)
if err != nil {
return nil, fmt.Errorf("error marshalling user: %v", err)
}
Expand Down
190 changes: 190 additions & 0 deletions statsig-go/test/statsig_user_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package test

import (
"encoding/json"
"testing"

statsig_go "github.com/statsig-io/statsig-go-core"
Expand All @@ -13,3 +14,192 @@ func TestStatsigUserBuilder(t *testing.T) {
t.Errorf("error creating StatsigUser: %v", err)
}
}

func TestStatsigUserBuilder_AllowNilUserID(t *testing.T) {
cases := []struct {
name string
allowNil bool
configure func(b *statsig_go.StatsigUserBuilder) *statsig_go.StatsigUserBuilder
}{
{
name: "default_with_user_id",
allowNil: false,
configure: func(b *statsig_go.StatsigUserBuilder) *statsig_go.StatsigUserBuilder { return b.WithUserID("u1") },
},
{
name: "default_with_empty_user_id",
allowNil: false,
configure: func(b *statsig_go.StatsigUserBuilder) *statsig_go.StatsigUserBuilder { return b.WithUserID("") },
},
{
name: "default_with_nil_user_id_custom_ids",
allowNil: false,
configure: func(b *statsig_go.StatsigUserBuilder) *statsig_go.StatsigUserBuilder { return b },
},
{
name: "optin_with_user_id",
allowNil: true,
configure: func(b *statsig_go.StatsigUserBuilder) *statsig_go.StatsigUserBuilder { return b.WithUserID("u1") },
},
{
name: "optin_with_empty_user_id",
allowNil: true,
configure: func(b *statsig_go.StatsigUserBuilder) *statsig_go.StatsigUserBuilder { return b.WithUserID("") },
},
{
name: "optin_with_nil_user_id_custom_ids",
allowNil: true,
configure: func(b *statsig_go.StatsigUserBuilder) *statsig_go.StatsigUserBuilder { return b },
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := newStatsigForUserBuilder(t, tc.allowNil)
b := tc.configure(s.NewUserBuilderWithCustomIDs(map[string]any{"stableID": "x"}))

user, err := b.Build()
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if user == nil {
t.Fatalf("Build() returned nil user with no error")
}
})
}
}

func TestStatsigUserBuilder_AllowNilUserID_FreeFunctionDefault(t *testing.T) {
cases := []struct {
name string
builder func() *statsig_go.StatsigUserBuilder
}{
{
name: "free_fn_with_user_id",
builder: func() *statsig_go.StatsigUserBuilder { return statsig_go.NewUserBuilderWithUserID("u1") },
},
{
name: "free_fn_custom_ids_only_coerces_nil",
builder: func() *statsig_go.StatsigUserBuilder { return statsig_go.NewUserBuilderWithCustomIDs(map[string]any{"stableID": "x"}) },
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
user, err := tc.builder().Build()
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if user == nil {
t.Fatalf("Build() returned nil user with no error")
}
})
}
}

func TestStatsigUserBuilder_BuildDoesNotMutate(t *testing.T) {
s := newStatsigForUserBuilder(t, false)
b := s.NewUserBuilderWithCustomIDs(map[string]any{"stableID": "x"})

if b.UserID != nil {
t.Fatalf("precondition: builder.UserID = %v, want nil", b.UserID)
}

if _, err := b.Build(); err != nil {
t.Fatalf("first Build() error = %v", err)
}
if b.UserID != nil {
t.Fatalf("after first Build(): builder.UserID = %v, want nil (Build must not mutate)", *b.UserID)
}

if _, err := b.Build(); err != nil {
t.Fatalf("second Build() error = %v", err)
}
if b.UserID != nil {
t.Fatalf("after second Build(): builder.UserID = %v, want nil (Build must not mutate)", *b.UserID)
}
}

func newStatsigForUserBuilder(t *testing.T, allowNilUserID bool) *statsig_go.Statsig {
t.Helper()
opts, err := statsig_go.NewOptionsBuilder().
WithAllowNilUserID(allowNilUserID).
Build()
if err != nil {
t.Fatalf("options Build() error = %v", err)
}
s, err := statsig_go.NewStatsigWithOptions("secret-test", opts)
if err != nil {
t.Fatalf("NewStatsigWithOptions error = %v", err)
}
return s
}

func TestStatsigUserBuilderJSONShape(t *testing.T) {
cases := []struct {
name string
builder *statsig_go.StatsigUserBuilder
wantUserIDKey bool
wantUserIDVal string
}{
{
name: "with_user_id",
builder: statsig_go.NewUserBuilderWithUserID("u1"),
wantUserIDKey: true,
wantUserIDVal: "u1",
},
{
name: "with_empty_user_id",
builder: statsig_go.NewUserBuilderWithUserID(""),
wantUserIDKey: true,
wantUserIDVal: "",
},
{
name: "custom_ids_only",
builder: statsig_go.NewUserBuilderWithCustomIDs(map[string]any{"stableID": "abc"}),
wantUserIDKey: false,
},
{
name: "override_to_empty",
builder: statsig_go.NewUserBuilderWithUserID("u1").WithUserID(""),
wantUserIDKey: true,
wantUserIDVal: "",
},
{
name: "custom_ids_then_user_id",
builder: statsig_go.NewUserBuilderWithCustomIDs(map[string]any{"stableID": "abc"}).WithUserID("u2"),
wantUserIDKey: true,
wantUserIDVal: "u2",
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
data, err := json.Marshal(tc.builder)
if err != nil {
t.Fatalf("json.Marshal failed: %v", err)
}

var m map[string]json.RawMessage
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("json.Unmarshal failed: %v", err)
}

raw, ok := m["userID"]
if ok != tc.wantUserIDKey {
t.Fatalf("userID key presence = %v, want %v (json=%s)", ok, tc.wantUserIDKey, string(data))
}
if !ok {
return
}

var got string
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("userID value not a JSON string: %v (raw=%s)", err, string(raw))
}
if got != tc.wantUserIDVal {
t.Errorf("userID = %q, want %q", got, tc.wantUserIDVal)
}
})
}
}