Skip to content

feat: user-scoped GrowthBookClient instances via create_scoped_instance - #141

Open
vazarkevych wants to merge 1 commit into
growthbook:mainfrom
vazarkevych:feat/user-scoped-instances
Open

vazarkevych wants to merge 1 commit into
growthbook:mainfrom
vazarkevych:feat/user-scoped-instances

Conversation

@vazarkevych

Copy link
Copy Markdown
Collaborator

Every evaluation on GrowthBookClient takes an explicit UserContext
argument — is_on(key, ctx), get_feature_value(key, fallback, ctx),
run(experiment, ctx), eval_feature, is_off, preload_remote_eval, and
optionally log_event. In request-scoped backends that means threading the
same context through every layer, and passing a mismatched one is a silent
correctness bug — the wrong user gets the wrong variation and nothing errors.

What this adds

GrowthBookClient.create_scoped_instance(user_context) returns a lightweight
per-user / per-request UserScopedGrowthBook handle whose evaluation methods
take no context argument:

gb = gb_client.create_scoped_instance(UserContext(attributes={"id": user_id}))

show_new_ui = await gb.is_on("new-ui")
color = await gb.get_feature_value("button-color", "blue")

Available on the handle: eval_feature, is_on, is_off,
get_feature_value, run, log_event, preload_remote_eval.

Every call delegates back to the shared client, so feature refresh, experiment
subscriptions, the feature-usage callback and the remote-eval cache are reused
rather than forked. The handle holds no evaluation state — creating and
discarding one per request is cheap.

Per-user state

The handle owns the state that belongs to one user, mutating its bound context:

  • set_attributes / update_attributes (shallow merge; None is a no-op)
  • set_url
  • set_forced_variations
  • set_forced_features
  • set_tracking_callback — overrides Options.on_experiment_viewed for this
    user only, falling back to it when unset
  • get_attributes() and a user_context property, for handing the context to
    a lower layer that still needs it

To support the per-user callback, UserContext gains an optional
tracking_callback and GrowthBookClient._track prefers it over the
client-level one. core.py needs no change — _track already receives the
user context.

All changes are additive and default to existing behavior.

Two decisions worth reviewing

The context is bound by reference, not copied. The setters write back to
the object you passed in, and external mutations of it are visible to the
handle. That is what makes user_context usable as a pass-through to plugins.
There is a test pinning this explicitly so it isn't later "optimized" into a
copy.

One handle per user. The bound context is mutable and also accumulates
sticky bucket assignments via create_evaluation_context, so sharing one
handle across concurrent requests for different users reintroduces exactly the
bug this class prevents. Documented on the class and in the README, matching
the existing README guidance about not sharing a UserContext across requests.

Pre-existing behavior called out

set_forced_features() is only honored in remote-eval mode, where the values
ship to the proxy in the eval payload. Local (CDN-mode) evaluation never reads
UserContext.forced_features — core.py doesn't reference it at all. That is
SDK-wide behavior this PR doesn't change, but an unqualified setter would read
as a local override, so both the docstring and the README say so and point at
set_forced_variations() for steering local evaluation.

Testing

Seven tests covering: evaluation without a context (including asserting the
handle and the client-with-explicit-context agree), independence of two handles
off one client, every state setter, reference-binding semantics, the per-user
tracking callback overriding the client-level one and falling back when
cleared, log_event passing the bound context, and preload_remote_eval
delegation without remote-eval configured.

792 tests pass, mypy clean, no new flake8 findings. README snippets were run
as written.

@greptile-apps

greptile-apps Bot commented Sep 15, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no actionable correctness, security, or contract failures were identified.

Reviews (1) · Last reviewed commit: "feat: user-scoped GrowthBookClient insta..."

Every evaluation on GrowthBookClient took an explicit UserContext argument
(is_on(key, ctx), get_feature_value(key, fallback, ctx), run(experiment, ctx),
and four more). In request-scoped backends that means threading the same
context through every layer, and passing a mismatched one is a silent
correctness bug.

Adds GrowthBookClient.create_scoped_instance(user_context) returning a
lightweight per-user/per-request UserScopedGrowthBook handle whose evaluation
methods take no context: eval_feature, is_on, is_off, get_feature_value, run,
log_event and preload_remote_eval. Every call delegates back to the shared
client, so feature refresh, experiment subscriptions, the feature-usage
callback and the remote-eval cache are reused rather than forked.

The handle also owns per-user state, mutating its bound context:
set_attributes, update_attributes (shallow merge), set_url,
set_forced_variations, set_forced_features, and a per-user
set_tracking_callback that overrides the client-level
Options.on_experiment_viewed for that user, falling back to it when unset.

To support the per-user callback, UserContext gains an optional
tracking_callback and GrowthBookClient._track prefers it over the client-level
one. core.py needs no change — _track already receives the user context.

The context is bound by reference rather than copied, so the setters write back
to the caller's object and external mutations are visible to the handle.

All changes are additive and default to existing behavior.

Documents in the README that set_forced_features is only honored in remote-eval
mode — local evaluation never reads UserContext.forced_features. Pre-existing
SDK-wide behavior, called out so the setter isn't mistaken for a local override.
@vazarkevych
vazarkevych force-pushed the feat/user-scoped-instances branch from 6765784 to 576bf29 Compare October 5, 2026 18:10
@andywhite37
andywhite37 self-requested a review October 8, 2026 21:28
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