Skip to content

[statsig-go] add opt-in for nil userid json omission#49

Open
jmcfarland-figma wants to merge 2 commits into
statsig-io:mainfrom
jmcfarland-figma:go-userid-omitempty
Open

[statsig-go] add opt-in for nil userid json omission#49
jmcfarland-figma wants to merge 2 commits into
statsig-io:mainfrom
jmcfarland-figma:go-userid-omitempty

Conversation

@jmcfarland-figma
Copy link
Copy Markdown

@jmcfarland-figma jmcfarland-figma commented May 8, 2026

Context

The Go StatsigUserBuilder always serialized userID to JSON, even when the caller never set it. A customIDs-only builder produced {"userID":""} on the wire, and the Rust core deserialized that into Some(DynamicValue("")) instead of None. The first commit on this branch added json:"userID,omitempty", which omitted the key entirely when UserID was nil. That's a behavioral change at the FFI boundary. null, "", and missing-key all hash differently in create_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 set UserID.

Approach

Gate the omission behind an SDK-level opt-in. Default behavior is backward-safe: when AllowNilUserID = false, Build() coerces a nil UserID to &"" on a local snapshot before marshalling, so the wire JSON carries "userID": "". When AllowNilUserID = true, the snapshot is left alone and the existing omitempty tag 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-function NewUserBuilderWith{UserID,CustomIDs} always defaults to false so existing call sites compile and keep their current behavior.

Build() works on a shallow copy of the builder, so a caller invoking Build() twice on the same builder gets identical results. No in-place mutation of UserID.

Note: the default-off path coerces nil to "", not nil to null. Both are non-nil at the Rust boundary, so evaluation results don't shift. Exposure-dedup hashes will shift one-time from ahash_str("null") to ahash_str("") for users that previously rode the pre-fix null path with no UserID set. Confirmed acceptable in design.

Design: designs-go-omit-nil-userid-opt-in.md

Reviewer guide

  • The load-bearing invariant: Build() must not mutate the caller's *StatsigUserBuilder. snapshot := *b does a shallow copy; only the UserID pointer field is swapped on the snapshot. TestStatsigUserBuilder_BuildDoesNotMutate covers this directly.
  • AllowNilUserID carries json:"-". It must never appear in the JSON forwarded to Rust.
  • Free functions keep their existing signatures, so any existing caller of NewUserBuilderWithUserID or NewUserBuilderWithCustomIDs compiles and behaves the same as before this PR (modulo null to "" for the nil-UserID + customIDs case, called out above).
  • The first commit on this branch (omitempty tag) is functionally inert when the opt-in is off. The coercion in Build() always produces a non-nil pointer before marshalling, so omitempty never triggers in the default path.

Changes

  • statsig-go/statsig_options.go: added AllowNilUserID bool (json:"-") on StatsigOptionsBuilder, new WithAllowNilUserID(bool) builder method, and propagated to the new private StatsigOptions.allowNilUserID field.
  • statsig-go/statsig.go: added private Statsig.allowNilUserID populated from options at construction; new (*Statsig).NewUserBuilderWithUserID and (*Statsig).NewUserBuilderWithCustomIDs propagate the flag to the builder.
  • statsig-go/statsig_user.go: UserID *string with json:"userID,omitempty" (first commit) plus private allowNilUserID field (second commit); Build() takes a shallow snapshot and coerces nil → &"" when the flag is off.
  • statsig-go/test/statsig_user_test.go: added TestStatsigUserBuilderJSONShape (wire-shape table-driven, 5 subtests), TestStatsigUserBuilder_AllowNilUserID (flag × UserID state matrix, 6 subtests through *Statsig instance methods), TestStatsigUserBuilder_AllowNilUserID_FreeFunctionDefault (free-function default behavior, 2 subtests), and TestStatsigUserBuilder_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

@jmcfarland-figma jmcfarland-figma changed the title [statsig-go] omit userID json field when unset [statsig-go] add opt-in for nil userid json omission May 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant