[statsig-go] add opt-in for nil userid json omission#49
Open
jmcfarland-figma wants to merge 2 commits into
Open
[statsig-go] add opt-in for nil userid json omission#49jmcfarland-figma wants to merge 2 commits into
jmcfarland-figma wants to merge 2 commits into
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
The Go
StatsigUserBuilderalways serializeduserIDto JSON, even when the caller never set it. A customIDs-only builder produced{"userID":""}on the wire, and the Rust core deserialized that intoSome(DynamicValue(""))instead ofNone. The first commit on this branch addedjson:"userID,omitempty", which omitted the key entirely whenUserIDwas nil. That's a behavioral change at the FFI boundary.null,"", and missing-key all hash differently increate_exposure_dedupe_user_hash(statsig-rust/src/user/user_data.rs:40-55), so flipping the wire shape silently shifts exposure-dedup keys for any caller that doesn't setUserID.Approach
Gate the omission behind an SDK-level opt-in. Default behavior is backward-safe: when
AllowNilUserID = false,Build()coerces a nilUserIDto&""on a local snapshot before marshalling, so the wire JSON carries"userID": "". WhenAllowNilUserID = true, the snapshot is left alone and the existingomitemptytag drops the key entirely.The flag lives on
StatsigOptions(Go-side only,json:"-"so it isn't forwarded to Rust). Rust has no concept of it. Two paths set it on a builder:(*Statsig).NewUserBuilderWith{UserID,CustomIDs}picks up the SDK-level value, and the free-functionNewUserBuilderWith{UserID,CustomIDs}always defaults tofalseso existing call sites compile and keep their current behavior.Build()works on a shallow copy of the builder, so a caller invokingBuild()twice on the same builder gets identical results. No in-place mutation ofUserID.Note: the default-off path coerces nil to
"", not nil tonull. Both are non-nil at the Rust boundary, so evaluation results don't shift. Exposure-dedup hashes will shift one-time fromahash_str("null")toahash_str("")for users that previously rode the pre-fixnullpath with noUserIDset. Confirmed acceptable in design.Design:
designs-go-omit-nil-userid-opt-in.mdReviewer guide
Build()must not mutate the caller's*StatsigUserBuilder.snapshot := *bdoes a shallow copy; only theUserIDpointer field is swapped on the snapshot.TestStatsigUserBuilder_BuildDoesNotMutatecovers this directly.AllowNilUserIDcarriesjson:"-". It must never appear in the JSON forwarded to Rust.NewUserBuilderWithUserIDorNewUserBuilderWithCustomIDscompiles and behaves the same as before this PR (modulonullto""for the nil-UserID + customIDs case, called out above).omitemptytag) is functionally inert when the opt-in is off. The coercion inBuild()always produces a non-nil pointer before marshalling, soomitemptynever triggers in the default path.Changes
statsig-go/statsig_options.go: addedAllowNilUserID bool(json:"-") onStatsigOptionsBuilder, newWithAllowNilUserID(bool)builder method, and propagated to the new privateStatsigOptions.allowNilUserIDfield.statsig-go/statsig.go: added privateStatsig.allowNilUserIDpopulated from options at construction; new(*Statsig).NewUserBuilderWithUserIDand(*Statsig).NewUserBuilderWithCustomIDspropagate the flag to the builder.statsig-go/statsig_user.go:UserID *stringwithjson:"userID,omitempty"(first commit) plus privateallowNilUserIDfield (second commit);Build()takes a shallow snapshot and coerces nil →&""when the flag is off.statsig-go/test/statsig_user_test.go: addedTestStatsigUserBuilderJSONShape(wire-shape table-driven, 5 subtests),TestStatsigUserBuilder_AllowNilUserID(flag × UserID state matrix, 6 subtests through*Statsiginstance methods),TestStatsigUserBuilder_AllowNilUserID_FreeFunctionDefault(free-function default behavior, 2 subtests), andTestStatsigUserBuilder_BuildDoesNotMutate(no-mutation invariant).Testing
cd statsig-go && go build ./...— passes.go test -run "TestStatsigUserBuilderJSONShape|TestStatsigUserBuilder_AllowNilUserID|TestStatsigUserBuilder_BuildDoesNotMutate" ./...— all subtests PASS.🤖 Generated with Claude Code