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
21 changes: 20 additions & 1 deletion lib/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8

group = 'com.github.growthbook'
version = 'main-SNAPSHOT'
version = findProperty('version') ?: 'main-SNAPSHOT'

repositories {
// Use Maven Central for resolving dependencies.
Expand All @@ -43,6 +43,7 @@ dependencies {
// Use JUnit Jupiter for testing.
testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2'
testImplementation 'org.mockito:mockito-inline:4.8.0'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.11.0'

implementation 'org.apache.commons:commons-math3:3.6.1'
implementation 'com.google.guava:guava:33.3.1-jre'
Expand All @@ -66,6 +67,15 @@ dependencies {
implementation 'org.slf4j:slf4j-api:2.0.7'
}

jar {
manifest {
attributes(
'Implementation-Title': 'growthbook-java-sdk',
'Implementation-Version': project.version
)
}
}

publishing {
publications {
mavenJava(MavenPublication) {
Expand Down Expand Up @@ -147,6 +157,15 @@ tasks.register('runPerfHarness', JavaExec) {
}
}

tasks.register('runTrackingPluginSmoke', JavaExec) {
group = 'verification'
description = 'Runs a local end-to-end smoke test for the GrowthBook tracking plugin.'
dependsOn testClasses
classpath = sourceSets.test.runtimeClasspath
mainClass = 'growthbook.sdk.java.plugin.tracking.TrackingPluginSmokeHarness'
systemProperty 'trackingSmokeMode', project.findProperty('trackingSmokeMode') ?: 'both'
}

// More configuration options, including enforcing code coverage available here:
// https://docs.gradle.org/current/userguide/jacoco_plugin.html
jacocoTestReport {
Expand Down
23 changes: 23 additions & 0 deletions lib/src/main/java/growthbook/sdk/java/GrowthBook.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import growthbook.sdk.java.multiusermode.configurations.UserContext;
import growthbook.sdk.java.multiusermode.usage.FeatureUsageCallbackAdapter;
import growthbook.sdk.java.multiusermode.usage.TrackingCallbackAdapter;
import growthbook.sdk.java.plugin.PluginRegistry;
import growthbook.sdk.java.stickyBucketing.InMemoryStickyBucketServiceImpl;
import growthbook.sdk.java.stickyBucketing.StickyBucketService;

Expand All @@ -52,6 +53,7 @@ public class GrowthBook implements IGrowthBook {

public EvaluationContext evaluationContext = null;
private final Map<String, AssignedExperiment> assigned;
private final PluginRegistry pluginRegistry;

@Getter @Setter private Map<String, Object> forcedFeatureValues;
/**
Expand All @@ -68,8 +70,10 @@ public GrowthBook(GBContext context) {
this.conditionEvaluator = new ConditionEvaluator();
this.experimentEvaluatorEvaluator = new ExperimentEvaluator();
this.attributeOverrides = context.getAttributes() == null ? new JsonObject() : context.getAttributes();
this.pluginRegistry = new PluginRegistry(context.getPlugins());

this.initializeEvalContext();
this.pluginRegistry.initAll();
}

/**
Expand All @@ -86,9 +90,11 @@ public GrowthBook() {
this.conditionEvaluator = new ConditionEvaluator();
this.experimentEvaluatorEvaluator = new ExperimentEvaluator();
this.attributeOverrides = context.getAttributes() == null ? new JsonObject() : context.getAttributes();
this.pluginRegistry = new PluginRegistry(context.getPlugins());


this.initializeEvalContext();
this.pluginRegistry.initAll();
}

/**
Expand All @@ -108,8 +114,10 @@ public GrowthBook() {
this.callbacks = new ArrayList<>();
this.attributeOverrides = context.getAttributes() == null ? new JsonObject() : context.getAttributes();
//this.savedGroups = context.getSavedGroups() == null ? new JsonObject() : context.getSavedGroups();
this.pluginRegistry = new PluginRegistry(context.getPlugins());

this.initializeEvalContext();
this.pluginRegistry.initAll();
}

private void initializeEvalContext() {
Expand All @@ -125,6 +133,9 @@ private void initializeEvalContext() {
.featureUsageCallbackWithUser(new FeatureUsageCallbackAdapter(this.context.getFeatureUsageCallback()))
.globalForcedFeatureValues(this.forcedFeatureValues)
.build();
if (this.pluginRegistry != null) {
options.setPluginRegistry(this.pluginRegistry);
}

// build global
GlobalContext globalContext = GlobalContext.builder()
Expand Down Expand Up @@ -499,6 +510,18 @@ public Double getFeatureValue(String featureKey, Double defaultValue) {
@Override
public void destroy() {
this.callbacks = new ArrayList<>();
close();
}

/**
* Flushes registered plugins (including the built-in tracking plugin)
* so any buffered events are sent before the instance is discarded.
* Safe to call multiple times.
*/
public void close() {
if (this.pluginRegistry != null) {
this.pluginRegistry.closeAll();
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import growthbook.sdk.java.model.VariationMeta;
import growthbook.sdk.java.multiusermode.configurations.EvaluationContext;
import growthbook.sdk.java.multiusermode.usage.TrackingCallbackWithUser;
import growthbook.sdk.java.plugin.PluginRegistry;
import lombok.extern.slf4j.Slf4j;

import javax.annotation.Nullable;
Expand Down Expand Up @@ -312,6 +313,11 @@ public <ValueType> ExperimentResult<ValueType> evaluateExperiment(Experiment<Val
if (trackingCallBackWithUser != null) {
trackingCallBackWithUser.onTrack(experiment, result, context.getUser());
}

PluginRegistry pluginRegistry = context.getOptions().getPluginRegistry();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

evaluateExperiment is already one of the largest methods in the codebase and
carries a lot of line-by-line "what" comments that restate the code. This change
adds another inline block to it, which pushes it further in the wrong direction.

Two things would keep it in check:

  1. Extract the dispatch instead of inlining it — mirror the dispatchFeatureUsage(...)
    helper this same PR already introduced in FeatureEvaluator, e.g.
    dispatchExperimentViewed(context, experiment, result). The method body then
    gains one call, not a new null-check block, and both evaluators stay consistent.

  2. While touching this block, trim the redundant comments (the ones that just
    re-describe the key formula / the callback) rather than growing the method. The
    goal here should be fewer lines and less noise, not more.

Net: the plugin hook is welcome, but let's add it via a small helper and take the
opportunity to shrink this method rather than extend it.

if (pluginRegistry != null) {
pluginRegistry.fireExperimentViewed(experiment, result, context);
}
}

// Return (in experiment, assigned variation)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import growthbook.sdk.java.multiusermode.configurations.EvaluationContext;
import growthbook.sdk.java.multiusermode.usage.FeatureUsageCallbackWithUser;
import growthbook.sdk.java.multiusermode.usage.TrackingCallbackWithUser;
import growthbook.sdk.java.plugin.PluginRegistry;
import lombok.extern.slf4j.Slf4j;
import javax.annotation.Nullable;
import java.net.MalformedURLException;
Expand Down Expand Up @@ -42,10 +43,6 @@ public <ValueType> FeatureResult<ValueType> evaluateFeature(
EvaluationContext context,
Class<ValueType> valueTypeClass
) throws ClassCastException {
// This callback serves for listening for feature usage events
FeatureUsageCallbackWithUser featureUsageCallbackWithUser = context.getOptions()
.getFeatureUsageCallbackWithUser();

FeatureResult<ValueType> unknownFeatureResult = FeatureResult
.<ValueType>builder()
.value(null)
Expand All @@ -66,9 +63,7 @@ public <ValueType> FeatureResult<ValueType> evaluateFeature(
.value(null)
.source(FeatureResultSource.CYCLIC_PREREQUISITE)
.build();
if (featureUsageCallbackWithUser != null) {
featureUsageCallbackWithUser.onFeatureUsage(key, featureResultWhenCircularDependencyDetected, context.getUser());
}
dispatchFeatureUsage(context, key, featureResultWhenCircularDependencyDetected);

leaveCircularLoop(context);
return featureResultWhenCircularDependencyDetected;
Expand Down Expand Up @@ -106,9 +101,7 @@ public <ValueType> FeatureResult<ValueType> evaluateFeature(
.source(FeatureResultSource.URL_OVERRIDE)
.build();

if (featureUsageCallbackWithUser != null) {
featureUsageCallbackWithUser.onFeatureUsage(key, urlFeatureResult, context.getUser());
}
dispatchFeatureUsage(context, key, urlFeatureResult);

return cacheResult(key, urlFeatureResult, context);
}
Expand All @@ -117,9 +110,7 @@ public <ValueType> FeatureResult<ValueType> evaluateFeature(
// Unknown key, return empty feature

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this PR is already refactoring evaluateFeature (swapping the inline
callback calls for dispatchFeatureUsage), it's a good moment to drop the
redundant comments in the method while you're in there. Most of them just restate
the next line ("Unknown key, return empty feature", "When key exists but there is
no value…", etc.) and add noise to an already long method.

Map<String, Feature<?>> features = context.getGlobal().getFeatures();
if (features == null || features.isEmpty() || !features.containsKey(key)) {
if (featureUsageCallbackWithUser != null) {
featureUsageCallbackWithUser.onFeatureUsage(key, unknownFeatureResult, context.getUser());
}
dispatchFeatureUsage(context, key, unknownFeatureResult);

return cacheResult(key, unknownFeatureResult, context);
}
Expand All @@ -134,9 +125,7 @@ public <ValueType> FeatureResult<ValueType> evaluateFeature(

if (feature == null) {
// When key exists but there is no value, should be default value with null value
if (featureUsageCallbackWithUser != null) {
featureUsageCallbackWithUser.onFeatureUsage(key, defaultValueFeature, context.getUser());
}
dispatchFeatureUsage(context, key, defaultValueFeature);
return cacheResult(key, defaultValueFeature, context);
}

Expand All @@ -148,9 +137,7 @@ public <ValueType> FeatureResult<ValueType> evaluateFeature(
.source(FeatureResultSource.DEFAULT_VALUE)
.value(value)
.build();
if (featureUsageCallbackWithUser != null) {
featureUsageCallbackWithUser.onFeatureUsage(key, defaultValueFeatureForRules, context.getUser());
}
dispatchFeatureUsage(context, key, defaultValueFeatureForRules);
return cacheResult(key, defaultValueFeatureForRules, context);
}

Expand Down Expand Up @@ -179,11 +166,7 @@ public <ValueType> FeatureResult<ValueType> evaluateFeature(
.source(FeatureResultSource.CYCLIC_PREREQUISITE)
.build();

if (featureUsageCallbackWithUser != null) {
featureUsageCallbackWithUser.onFeatureUsage(key,
featureResultWhenCircularDependencyDetected,
context.getUser());
}
dispatchFeatureUsage(context, key, featureResultWhenCircularDependencyDetected);
return cacheResult(key, featureResultWhenCircularDependencyDetected, context);
}

Expand Down Expand Up @@ -212,11 +195,7 @@ public <ValueType> FeatureResult<ValueType> evaluateFeature(
.source(FeatureResultSource.PREREQUISITE)
.build();

if (featureUsageCallbackWithUser != null) {
featureUsageCallbackWithUser.onFeatureUsage(key,
featureResultWhenBlockedByPrerequisite,
context.getUser());
}
dispatchFeatureUsage(context, key, featureResultWhenBlockedByPrerequisite);
return cacheResult(key, featureResultWhenBlockedByPrerequisite, context);
}
// non-blocking prerequisite eval failed: break out
Expand Down Expand Up @@ -284,16 +263,26 @@ public <ValueType> FeatureResult<ValueType> evaluateFeature(
// Call the tracking callback with all the track data
List<TrackData<ValueType>> trackData = rule.getTracks();
TrackingCallbackWithUser trackingCallBackWithUser = context.getOptions().getTrackingCallBackWithUser();
PluginRegistry pluginRegistry = context.getOptions().getPluginRegistry();

@vazarkevych vazarkevych Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue again — this just bolts more inline logic onto an already large evaluateFeature. Would you mind extracting the onTrack + fireExperimentViewed dispatch into a shared helper instead of growing the method further? Should keep things a bit tidier going forward, thanks!


// If this was a remotely evaluated experiment, fire the tracking callbacks
if (trackData != null && trackingCallBackWithUser != null) {
trackData.forEach(t ->
if (trackData != null) {
trackData.forEach(t -> {

@vazarkevych vazarkevych Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since main already solved this in #216 with experimentEvaluator.fireRemoteEvaluationTracks(...) (dedupes exposures, null-guards payloads), it'd be worth rebasing onto that instead of adding a parallel helper here. Could you route the plugin's fireExperimentViewed through that existing path once you've rebased? Should save us from maintaining two versions of the same logic.

if (trackingCallBackWithUser != null) {
trackingCallBackWithUser.onTrack(
t.getExperiment(),
t.getResult().getExperimentResult(),
context.getUser()
)
);
);
}
if (pluginRegistry != null) {
pluginRegistry.fireExperimentViewed(
t.getExperiment(),
t.getResult().getExperimentResult(),
context
);
}
});
}

if (rule.getRange() == null) {
Expand Down Expand Up @@ -325,9 +314,7 @@ public <ValueType> FeatureResult<ValueType> evaluateFeature(
.ruleId(rule.getId())
.build();

if (featureUsageCallbackWithUser != null) {
featureUsageCallbackWithUser.onFeatureUsage(key, forcedRuleFeatureValue, context.getUser());
}
dispatchFeatureUsage(context, key, forcedRuleFeatureValue);

return cacheResult(key, forcedRuleFeatureValue, context);
} else {
Expand Down Expand Up @@ -378,9 +365,7 @@ public <ValueType> FeatureResult<ValueType> evaluateFeature(
.experimentResult(result)
.build();

if (featureUsageCallbackWithUser != null) {
featureUsageCallbackWithUser.onFeatureUsage(key, experimentFeatureResult, context.getUser());
}
dispatchFeatureUsage(context, key, experimentFeatureResult);
return cacheResult(key, experimentFeatureResult, context);
}
} else {
Expand All @@ -399,9 +384,7 @@ public <ValueType> FeatureResult<ValueType> evaluateFeature(
.value(value)
.build();

if (featureUsageCallbackWithUser != null) {
featureUsageCallbackWithUser.onFeatureUsage(key, defaultValueFeatureResult, context.getUser());
}
dispatchFeatureUsage(context, key, defaultValueFeatureResult);

// Return (value = defaultValue or null, source = defaultValue)
return cacheResult(key, defaultValueFeatureResult, context);
Expand Down Expand Up @@ -458,6 +441,21 @@ private void addFeatureToEvalStack(String featureKey, EvaluationContext context)
context.getStack().getEvaluatedFeatures().add(featureKey);
}

private <ValueType> void dispatchFeatureUsage(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValueType isn't idiomatic Java. The convention for type parameters is a
single uppercase letter (T, or V for a value), not a descriptive word — the JLS
naming guideline exists precisely so type variables are visually distinct from
class names.

EvaluationContext context,
String key,
FeatureResult<ValueType> result
) {
FeatureUsageCallbackWithUser cb = context.getOptions().getFeatureUsageCallbackWithUser();
if (cb != null) {
cb.onFeatureUsage(key, result, context.getUser());
}
PluginRegistry registry = context.getOptions().getPluginRegistry();
if (registry != null) {
registry.fireFeatureEvaluated(key, result, context);
}
}

private <ValueType> FeatureResult<ValueType> cacheResult(String key, FeatureResult<ValueType> result, EvaluationContext context) {
context.getStack().getMemoizedResults().putIfAbsent(key, result);
return result;
Expand Down
13 changes: 12 additions & 1 deletion lib/src/main/java/growthbook/sdk/java/model/GBContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import growthbook.sdk.java.callback.FeatureUsageCallback;
import growthbook.sdk.java.callback.TrackingCallback;
import growthbook.sdk.java.multiusermode.util.TransformationUtil;
import growthbook.sdk.java.plugin.GrowthBookPlugin;
import growthbook.sdk.java.stickyBucketing.StickyBucketService;
import lombok.Builder;
import lombok.Data;
Expand Down Expand Up @@ -61,7 +62,8 @@ public GBContext(
@Nullable StickyBucketService stickyBucketService,
@Nullable Map<String, StickyAssignmentsDocument> stickyBucketAssignmentDocs,
@Nullable List<String> stickyBucketIdentifierAttributes,
@Nullable JsonObject savedGroups
@Nullable JsonObject savedGroups,
@Nullable List<GrowthBookPlugin> plugins
) {
this.encryptionKey = encryptionKey;
this.attributesJson = attributesJson == null ? "{}" : attributesJson;
Expand All @@ -85,6 +87,7 @@ public GBContext(
this.stickyBucketAssignmentDocs = stickyBucketAssignmentDocs;
this.stickyBucketIdentifierAttributes = stickyBucketIdentifierAttributes;
this.savedGroups = savedGroups == null ? new JsonObject() : savedGroups;
this.plugins = plugins;
}

/**
Expand Down Expand Up @@ -234,6 +237,14 @@ public void setFeaturesJson(String featuresJson) {
@Nullable
private String stickyBucketIdentifierAttributesSignature;

/**
* Plugins registered with the GrowthBook instance. See
* {@link GrowthBookPlugin} and
* {@link growthbook.sdk.java.plugin.tracking.GrowthBookTrackingPlugin}.
*/
@Nullable
private List<GrowthBookPlugin> plugins;

/**
* The builder class to help create a context. You can use {@link #builder()} or the {@link GBContext} constructor
*/
Expand Down
Loading
Loading