Repository navigation
feat: user-scoped GrowthBookClient instances via createScopedInstance - #259
vazarkevych wants to merge 2 commits into
Conversation
Add GrowthBookClient.createScopedInstance(UserContext), returning a lightweight per-user/per-request UserScopedGrowthBook handle whose evaluation methods (isOn, isOff, evalFeature, getFeatureValue, run, and the typed FeatureKey variants) take no context argument. Every call delegates back to the shared client, so feature refresh, experiment subscriptions, and remote evaluation are reused rather than forked. The scoped instance also owns per-user state, mutating a bound context: setForcedFeatures, setForcedVariations, updateAttributes/setAttributes, setURL, and a per-user setTrackingCallback that overrides the client-level callback for that user, falling back to it when unset. To support the per-user callback, UserContext gains an optional trackingCallback (threaded through its copy builders and a new toBuilder()), and ExperimentEvaluator prefers it over the client-level one via resolveTrackingCallback — applied in the shared dispatchExperimentViewed path so both local and remote-eval exposures honor it. All changes are additive and default to existing behavior. Mirrors the TS SDK createScopedInstance / UserScopedGrowthBook API.
|
Scoped URL overrides set through a user-scoped instance were ignored during local evaluation because evaluators read Options.getUrl() directly. Add EvaluationContext.resolveUrl(), which prefers UserContext.getUrl() and falls back to the client-level Options URL without mutating shared options, and route URL-based targeting and query-string overrides in FeatureEvaluator and ExperimentEvaluator through it. Replace the loopback com.sun HttpServer fixture in UserScopedGrowthBookTest with WireMock per the repository testing convention.
|
@greptile review |
| public String resolveUrl() { | ||
| String userUrl = this.user != null ? this.user.getUrl() : null; | ||
| if (userUrl != null) { | ||
| return userUrl; | ||
| } | ||
| return this.options != null ? this.options.getUrl() : null; |
There was a problem hiding this comment.
Scoped URL overrides lack tests
resolveUrl() changes which URL feature and experiment overrides use, but no test checks evaluation with a per-user URL. The existing setURL test only checks the saved value; other override tests use Options.setUrl().
The repository guide requires tests for every behavior change, including valid, invalid, and absent inputs. Before merging, add tests for per-user precedence, client fallback, and empty or malformed URLs in both override paths.
Context Used: AGENTS.md (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/EvaluationContext.java
Line: 46-51
Comment:
**Scoped URL overrides lack tests**
`resolveUrl()` changes which URL feature and experiment overrides use, but no test checks evaluation with a per-user URL. The existing `setURL` test only checks the saved value; other override tests use `Options.setUrl()`.
The repository guide requires tests for every behavior change, including valid, invalid, and absent inputs. Before merging, add tests for per-user precedence, client fallback, and empty or malformed URLs in both override paths.
**Context Used:** AGENTS.md ([source](https://github.com/growthbook/growthbook-sdk-java/blob/main/AGENTS.md))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
feat: user-scoped GrowthBookClient instances via createScopedInstance
A lightweight per-user/per-request handle over the shared
GrowthBookClient— context-free evaluation + per-user stateTypeScript-SDK parity (
createScopedInstance/UserScopedGrowthBook) · additive · shared client reusedBranch:
feat/user-scoped-instances→mainCommit:
fd8a8acSummary
Adds
GrowthBookClient.createScopedInstance(UserContext), returning aUserScopedGrowthBookbound toone user/request. Its evaluation methods (
isOn,isOff,evalFeature,getFeatureValue,run, andthe typed
FeatureKeyvariants) take no context argument — every call delegates back to the sharedclient, so feature refresh, experiment subscriptions, and remote evaluation are reused, not forked.
The scoped handle also owns per-user state (forced features/variations, attributes, URL, and a
per-user tracking callback). The change is additive and backward compatible.
Why
UserContextinto every call (isOn(key, ctx),getFeatureValue(key, default, ctx),run(experiment, ctx)) means threading the same context throughevery layer of a request; a mismatched context is a silent correctness bug. A scoped instance binds it
once.
one for just that user's evaluations (e.g. per-request logging context).
createScopedInstance/UserScopedGrowthBook.How it wires up
flowchart LR C[GrowthBookClient] -->|createScopedInstance| U[UserScopedGrowthBook] U -->|isOn / run / getFeatureValue| C U -->|setForced* / setAttributes / setURL / setTrackingCallback| CTX["bound UserContext (rebuilt via toBuilder)"] C -->|evaluate with| CTX CTX -->|trackingCallback| EV[ExperimentEvaluator.resolveTrackingCallback] EV -->|per-user over client-level| T[tracking]The scoped instance holds no evaluation logic of its own: it stores a bound
UserContextand forwardsto the shared client. Mutators rebuild the context (via
UserContext.toBuilder()) rather than mutatingit in place.
What was done
1.
UserScopedGrowthBook(multiusermode/UserScopedGrowthBook.java, new)Per-user handle: context-free evaluation methods that delegate to the client, plus chainable per-user
mutators (
setForcedFeatures,setForcedVariations,updateAttributes,setAttributes,setURL,setTrackingCallback). Documented as single-request/thread use (the client stays the shared, long-livedinstance).
2.
GrowthBookClient(multiusermode/GrowthBookClient.java)createScopedInstance(UserContext)factory.3.
UserContext(multiusermode/configurations/UserContext.java)Optional
trackingCallback(field, builder setter, getter), threaded through the existing copy builders(
witAttributesJson,withAttributes) and a newtoBuilder()used by the scoped mutators.4.
ExperimentEvaluator(evaluators/ExperimentEvaluator.java)resolveTrackingCallback(context)prefers the per-user callback over the client-level one. Applied inthe shared
dispatchExperimentViewedpath (and the remote-eval early-return guard), so both localand remote-eval exposures honor a per-user callback.
Files
multiusermode/UserScopedGrowthBook.javamultiusermode/GrowthBookClient.javacreateScopedInstance(UserContext)multiusermode/configurations/UserContext.javatrackingCallback+toBuilder(); threaded through copy buildersevaluators/ExperimentEvaluator.javaresolveTrackingCallback(per-user precedence) in the shared dispatch pathmultiusermode/UserScopedGrowthBookTest.javaREADME.mdUsage
Java — per-request handle
Public API surface
GrowthBookClient.createScopedInstance(UserContext)UserScopedGrowthBook(class + methods)UserContext.builder().trackingCallback(...)/getTrackingCallback()/toBuilder()UserContext/ExperimentEvaluatorbehaviorTests
UserScopedGrowthBookTest(13 cases) covers: context-free delegation (isOn/isOff/evalFeature/getFeatureValue); forced features/variations;updateAttributes(merge) vssetAttributes(replace);setURLtargeting; typedFeatureKeyaccessors;run; and per-usersetTrackingCallbacktakingprecedence over the client-level callback (with fallback when unset). Uses a loopback
com.sun.net.httpserver.HttpServerserving a fixed payload (the repo's established pattern), so it isdeterministic with no real network.
./gradlew buildpasses on JDK 17; the full:libsuite is green.Compatibility & scope notes
client-level callback when unset.
GrowthBookClientpath. Single-contextGrowthBook/GBContextis a different, caller-owned model and is untouched.
feat/user-scoped-instancesbranch, adapted. PublicExperimentEvaluatorroutes both local and remote exposures through a single
dispatchExperimentViewed, so the per-userprecedence is applied there once rather than at the two separate call sites the internal branch patched.