diff --git a/lib/build.gradle b/lib/build.gradle index d95c7c83..0fe7d75a 100644 --- a/lib/build.gradle +++ b/lib/build.gradle @@ -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. @@ -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' @@ -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) { @@ -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 { diff --git a/lib/src/main/java/growthbook/sdk/java/GrowthBook.java b/lib/src/main/java/growthbook/sdk/java/GrowthBook.java index 5b24e092..b96cc62c 100644 --- a/lib/src/main/java/growthbook/sdk/java/GrowthBook.java +++ b/lib/src/main/java/growthbook/sdk/java/GrowthBook.java @@ -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; @@ -52,6 +53,7 @@ public class GrowthBook implements IGrowthBook { public EvaluationContext evaluationContext = null; private final Map assigned; + private final PluginRegistry pluginRegistry; @Getter @Setter private Map forcedFeatureValues; /** @@ -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(); } /** @@ -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(); } /** @@ -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() { @@ -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() @@ -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(); + } } /** diff --git a/lib/src/main/java/growthbook/sdk/java/evaluators/ExperimentEvaluator.java b/lib/src/main/java/growthbook/sdk/java/evaluators/ExperimentEvaluator.java index 9894ffd9..3839db8b 100644 --- a/lib/src/main/java/growthbook/sdk/java/evaluators/ExperimentEvaluator.java +++ b/lib/src/main/java/growthbook/sdk/java/evaluators/ExperimentEvaluator.java @@ -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; @@ -312,6 +313,11 @@ public ExperimentResult evaluateExperiment(Experiment FeatureResult evaluateFeature( EvaluationContext context, Class valueTypeClass ) throws ClassCastException { - // This callback serves for listening for feature usage events - FeatureUsageCallbackWithUser featureUsageCallbackWithUser = context.getOptions() - .getFeatureUsageCallbackWithUser(); - FeatureResult unknownFeatureResult = FeatureResult .builder() .value(null) @@ -66,9 +63,7 @@ public FeatureResult 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; @@ -106,9 +101,7 @@ public FeatureResult evaluateFeature( .source(FeatureResultSource.URL_OVERRIDE) .build(); - if (featureUsageCallbackWithUser != null) { - featureUsageCallbackWithUser.onFeatureUsage(key, urlFeatureResult, context.getUser()); - } + dispatchFeatureUsage(context, key, urlFeatureResult); return cacheResult(key, urlFeatureResult, context); } @@ -117,9 +110,7 @@ public FeatureResult evaluateFeature( // Unknown key, return empty feature Map> 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); } @@ -134,9 +125,7 @@ public FeatureResult 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); } @@ -148,9 +137,7 @@ public FeatureResult 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); } @@ -179,11 +166,7 @@ public FeatureResult evaluateFeature( .source(FeatureResultSource.CYCLIC_PREREQUISITE) .build(); - if (featureUsageCallbackWithUser != null) { - featureUsageCallbackWithUser.onFeatureUsage(key, - featureResultWhenCircularDependencyDetected, - context.getUser()); - } + dispatchFeatureUsage(context, key, featureResultWhenCircularDependencyDetected); return cacheResult(key, featureResultWhenCircularDependencyDetected, context); } @@ -212,11 +195,7 @@ public FeatureResult 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 @@ -284,16 +263,26 @@ public FeatureResult evaluateFeature( // Call the tracking callback with all the track data List> trackData = rule.getTracks(); TrackingCallbackWithUser trackingCallBackWithUser = context.getOptions().getTrackingCallBackWithUser(); + PluginRegistry pluginRegistry = context.getOptions().getPluginRegistry(); // 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 -> { + 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) { @@ -325,9 +314,7 @@ public FeatureResult evaluateFeature( .ruleId(rule.getId()) .build(); - if (featureUsageCallbackWithUser != null) { - featureUsageCallbackWithUser.onFeatureUsage(key, forcedRuleFeatureValue, context.getUser()); - } + dispatchFeatureUsage(context, key, forcedRuleFeatureValue); return cacheResult(key, forcedRuleFeatureValue, context); } else { @@ -378,9 +365,7 @@ public FeatureResult evaluateFeature( .experimentResult(result) .build(); - if (featureUsageCallbackWithUser != null) { - featureUsageCallbackWithUser.onFeatureUsage(key, experimentFeatureResult, context.getUser()); - } + dispatchFeatureUsage(context, key, experimentFeatureResult); return cacheResult(key, experimentFeatureResult, context); } } else { @@ -399,9 +384,7 @@ public FeatureResult 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); @@ -458,6 +441,21 @@ private void addFeatureToEvalStack(String featureKey, EvaluationContext context) context.getStack().getEvaluatedFeatures().add(featureKey); } + private void dispatchFeatureUsage( + EvaluationContext context, + String key, + FeatureResult 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 FeatureResult cacheResult(String key, FeatureResult result, EvaluationContext context) { context.getStack().getMemoizedResults().putIfAbsent(key, result); return result; diff --git a/lib/src/main/java/growthbook/sdk/java/model/GBContext.java b/lib/src/main/java/growthbook/sdk/java/model/GBContext.java index dd69a310..3e0bf3dd 100644 --- a/lib/src/main/java/growthbook/sdk/java/model/GBContext.java +++ b/lib/src/main/java/growthbook/sdk/java/model/GBContext.java @@ -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; @@ -61,7 +62,8 @@ public GBContext( @Nullable StickyBucketService stickyBucketService, @Nullable Map stickyBucketAssignmentDocs, @Nullable List stickyBucketIdentifierAttributes, - @Nullable JsonObject savedGroups + @Nullable JsonObject savedGroups, + @Nullable List plugins ) { this.encryptionKey = encryptionKey; this.attributesJson = attributesJson == null ? "{}" : attributesJson; @@ -85,6 +87,7 @@ public GBContext( this.stickyBucketAssignmentDocs = stickyBucketAssignmentDocs; this.stickyBucketIdentifierAttributes = stickyBucketIdentifierAttributes; this.savedGroups = savedGroups == null ? new JsonObject() : savedGroups; + this.plugins = plugins; } /** @@ -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 plugins; + /** * The builder class to help create a context. You can use {@link #builder()} or the {@link GBContext} constructor */ diff --git a/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java b/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java index bc6413af..33e7cb7f 100644 --- a/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java +++ b/lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java @@ -16,6 +16,7 @@ import growthbook.sdk.java.multiusermode.configurations.GlobalContext; import growthbook.sdk.java.multiusermode.configurations.Options; import growthbook.sdk.java.multiusermode.configurations.UserContext; +import growthbook.sdk.java.plugin.PluginRegistry; import growthbook.sdk.java.repository.GBFeaturesRepository; import growthbook.sdk.java.sandbox.CacheManagerFactory; import growthbook.sdk.java.sandbox.CacheMode; @@ -54,6 +55,10 @@ public GrowthBookClient(Options opts) { this.featureEvaluator = new FeatureEvaluator(); this.experimentEvaluatorEvaluator = new ExperimentEvaluator(); this.callbacks = new ArrayList<>(); + + PluginRegistry registry = new PluginRegistry(this.options.getPlugins()); + this.options.setPluginRegistry(registry); + registry.initAll(); } public boolean initialize() { @@ -191,6 +196,19 @@ public void shutdown() { repository = null; log.info("Repository shut down"); } + close(); + } + + /** + * Flushes registered plugins (including the built-in tracking plugin) + * so any buffered events are sent before the client is discarded. + * Safe to call multiple times. + */ + public void close() { + PluginRegistry registry = this.options.getPluginRegistry(); + if (registry != null) { + registry.closeAll(); + } } private void fireSubscriptions(Experiment experiment, ExperimentResult result) { diff --git a/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java b/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java index 0271e377..fd59d245 100644 --- a/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java +++ b/lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java @@ -8,6 +8,8 @@ import growthbook.sdk.java.multiusermode.usage.FeatureUsageCallbackWithUser; import growthbook.sdk.java.multiusermode.usage.TrackingCallbackWithUser; import growthbook.sdk.java.multiusermode.util.TransformationUtil; +import growthbook.sdk.java.plugin.GrowthBookPlugin; +import growthbook.sdk.java.plugin.PluginRegistry; import growthbook.sdk.java.repository.FeatureRefreshStrategy; import growthbook.sdk.java.sandbox.GbCacheManager; import growthbook.sdk.java.sandbox.CacheMode; @@ -47,7 +49,8 @@ public Options(@Nullable Boolean enabled, @Nullable Map globalForcedVariationsMap, @Nullable GbCacheManager cacheManager, @Nullable CacheMode cacheMode, - @Nullable String cacheDirectory + @Nullable String cacheDirectory, + @Nullable List plugins ) { this.enabled = enabled == null || enabled; @@ -71,6 +74,7 @@ public Options(@Nullable Boolean enabled, this.cacheManager = cacheManager; this.cacheMode = cacheMode == null ? CacheMode.AUTO : cacheMode; this.cacheDirectory = cacheDirectory; + this.plugins = plugins; } /** @@ -197,6 +201,22 @@ public FeatureRefreshStrategy getRefreshingStrategy() { @Nullable private String cacheDirectory; + /** + * Plugins registered with the GrowthBook client. See + * {@link GrowthBookPlugin} and + * {@link growthbook.sdk.java.plugin.tracking.GrowthBookTrackingPlugin}. + */ + @Nullable + private List plugins; + + /** + * Internal: set by {@code GrowthBookClient}/{@code GrowthBook} after + * constructing a registry from {@link #plugins}. Exposed so the evaluators + * can dispatch events without knowing which caller built the registry. + */ + @Nullable + private PluginRegistry pluginRegistry; + public CacheMode getCacheMode() { return cacheMode == null ? CacheMode.AUTO : cacheMode; } @Nullable public String getCacheDirectory() { return cacheDirectory; } diff --git a/lib/src/main/java/growthbook/sdk/java/plugin/GrowthBookPlugin.java b/lib/src/main/java/growthbook/sdk/java/plugin/GrowthBookPlugin.java new file mode 100644 index 00000000..a74c6d3b --- /dev/null +++ b/lib/src/main/java/growthbook/sdk/java/plugin/GrowthBookPlugin.java @@ -0,0 +1,72 @@ +package growthbook.sdk.java.plugin; + +import growthbook.sdk.java.model.Experiment; +import growthbook.sdk.java.model.ExperimentResult; +import growthbook.sdk.java.model.FeatureResult; +import growthbook.sdk.java.multiusermode.configurations.EvaluationContext; + +/** + * A plugin that can observe experiment and feature evaluations on a GrowthBook + * instance. Mirrors the plugin contracts in the GrowthBook Python and Go SDKs. + * + *

Implementations MUST be thread-safe. All methods default to no-ops so + * custom plugins only override what they need. Methods should return quickly + * (enqueue work, don't block evaluation). Exceptions thrown by these methods + * are caught by {@link PluginRegistry} so one failing plugin can't break + * evaluation or other plugins. + */ +public interface GrowthBookPlugin { + + /** + * Called once when the plugin is registered with a GrowthBook instance. + * If this throws, the plugin is treated as failed and the other methods + * still need to remain safe to call (no-op is acceptable). + */ + default void init() { + } + + /** + * Invoked after a user is bucketed into an experiment (once per + * unique hashAttribute/hashValue/experiment.key/variation combination). + */ + default void onExperimentViewed(Experiment experiment, ExperimentResult result) { + } + + /** + * Invoked after a user is bucketed into an experiment, with the evaluation + * context that triggered the event. + */ + default void onExperimentViewed( + Experiment experiment, + ExperimentResult result, + EvaluationContext context + ) { + onExperimentViewed(experiment, result); + } + + /** + * Invoked every time a feature is evaluated. + */ + default void onFeatureEvaluated(String featureKey, FeatureResult result) { + } + + /** + * Invoked every time a feature is evaluated, with the evaluation context + * that triggered the event. + */ + default void onFeatureEvaluated( + String featureKey, + FeatureResult result, + EvaluationContext context + ) { + onFeatureEvaluated(featureKey, result); + } + + /** + * Invoked when the owning GrowthBook instance is closed. Implementations + * should flush any buffered work synchronously. MUST be safe to call + * multiple times and even if {@link #init()} failed. + */ + default void close() { + } +} diff --git a/lib/src/main/java/growthbook/sdk/java/plugin/PluginRegistry.java b/lib/src/main/java/growthbook/sdk/java/plugin/PluginRegistry.java new file mode 100644 index 00000000..49033ea3 --- /dev/null +++ b/lib/src/main/java/growthbook/sdk/java/plugin/PluginRegistry.java @@ -0,0 +1,108 @@ +package growthbook.sdk.java.plugin; + +import growthbook.sdk.java.model.Experiment; +import growthbook.sdk.java.model.ExperimentResult; +import growthbook.sdk.java.model.FeatureResult; +import growthbook.sdk.java.multiusermode.configurations.EvaluationContext; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Holds a set of {@link GrowthBookPlugin}s registered with a GrowthBook + * instance and dispatches lifecycle/event callbacks to each one. + * + *

Dispatch is best-effort: exceptions from one plugin never propagate + * to the evaluator or other plugins. {@link #initAll()} captures init failures + * so a failing plugin becomes a no-op rather than aborting registration. + */ +@Slf4j +public final class PluginRegistry { + + private final List plugins; + + public PluginRegistry(@Nullable List plugins) { + if (plugins == null || plugins.isEmpty()) { + this.plugins = Collections.emptyList(); + } else { + this.plugins = new ArrayList<>(plugins); + } + } + + public boolean isEmpty() { + return plugins.isEmpty(); + } + + public void initAll() { + for (GrowthBookPlugin plugin : plugins) { + try { + plugin.init(); + } catch (Throwable t) { + log.warn("Plugin {} init failed; continuing as no-op", + plugin.getClass().getName(), t); + } + } + } + + public void fireExperimentViewed(Experiment experiment, ExperimentResult result) { + fireExperimentViewed(experiment, result, null); + } + + public void fireExperimentViewed( + Experiment experiment, + ExperimentResult result, + @Nullable EvaluationContext context + ) { + if (plugins.isEmpty()) return; + for (GrowthBookPlugin plugin : plugins) { + try { + if (context == null) { + plugin.onExperimentViewed(experiment, result); + } else { + plugin.onExperimentViewed(experiment, result, context); + } + } catch (Throwable t) { + log.warn("Plugin {} onExperimentViewed failed", + plugin.getClass().getName(), t); + } + } + } + + public void fireFeatureEvaluated(String featureKey, FeatureResult result) { + fireFeatureEvaluated(featureKey, result, null); + } + + public void fireFeatureEvaluated( + String featureKey, + FeatureResult result, + @Nullable EvaluationContext context + ) { + if (plugins.isEmpty()) return; + for (GrowthBookPlugin plugin : plugins) { + try { + if (context == null) { + plugin.onFeatureEvaluated(featureKey, result); + } else { + plugin.onFeatureEvaluated(featureKey, result, context); + } + } catch (Throwable t) { + log.warn("Plugin {} onFeatureEvaluated failed", + plugin.getClass().getName(), t); + } + } + } + + public void closeAll() { + for (GrowthBookPlugin plugin : plugins) { + try { + plugin.close(); + } catch (Throwable t) { + log.warn("Plugin {} close failed", + plugin.getClass().getName(), t); + } + } + } +} diff --git a/lib/src/main/java/growthbook/sdk/java/plugin/tracking/GrowthBookTrackingPlugin.java b/lib/src/main/java/growthbook/sdk/java/plugin/tracking/GrowthBookTrackingPlugin.java new file mode 100644 index 00000000..aa42ab54 --- /dev/null +++ b/lib/src/main/java/growthbook/sdk/java/plugin/tracking/GrowthBookTrackingPlugin.java @@ -0,0 +1,305 @@ +package growthbook.sdk.java.plugin.tracking; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import growthbook.sdk.java.model.Experiment; +import growthbook.sdk.java.model.ExperimentResult; +import growthbook.sdk.java.model.FeatureResult; +import growthbook.sdk.java.multiusermode.configurations.EvaluationContext; +import growthbook.sdk.java.multiusermode.configurations.UserContext; +import growthbook.sdk.java.plugin.GrowthBookPlugin; +import growthbook.sdk.java.util.GrowthBookJsonUtils; +import lombok.extern.slf4j.Slf4j; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Batches experiment/feature evaluation events and POSTs them to the + * GrowthBook data-warehouse ingest endpoint. Wire contract mirrors the Go + * SDK: {@code POST {ingestorHost}/events} with JSON body + * {@code {"client_key": ..., "events": [...]}}. + * + *

A flush is triggered when either the buffer reaches + * {@link TrackingPluginConfig#resolvedBatchSize()} or a timer fires after + * {@link TrackingPluginConfig#resolvedBatchTimeout()}. {@link #close()} + * performs a final synchronous flush. + * + *

If {@link TrackingPluginConfig#getClientKey()} is null/empty the plugin + * becomes a no-op: event methods return immediately, no HTTP traffic occurs, + * and {@link #close()} still completes cleanly. + */ +@Slf4j +public final class GrowthBookTrackingPlugin implements GrowthBookPlugin { + + private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + private static final AtomicInteger THREAD_COUNTER = new AtomicInteger(); + + private final TrackingPluginConfig config; + private final OkHttpClient httpClient; + private final boolean ownsHttpClient; + private final ScheduledExecutorService scheduler; + private final Executor flushExecutor; + private final boolean ownsFlushExecutor; + + private final ReentrantLock lock = new ReentrantLock(); + private final List buffer = new ArrayList<>(); + @Nullable + private ScheduledFuture pendingFlush; + + private final AtomicBoolean closed = new AtomicBoolean(false); + private final boolean disabled; + + private GrowthBookTrackingPlugin(TrackingPluginConfig config) { + this.config = config; + this.disabled = config.getClientKey() == null || config.getClientKey().isEmpty(); + + if (config.getHttpClient() != null) { + this.httpClient = config.getHttpClient(); + this.ownsHttpClient = false; + } else { + this.httpClient = new OkHttpClient(); + this.ownsHttpClient = true; + } + + this.scheduler = Executors.newSingleThreadScheduledExecutor( + daemonFactory("growthbook-tracking-plugin-scheduler")); + + if (config.getFlushExecutor() != null) { + this.flushExecutor = config.getFlushExecutor(); + this.ownsFlushExecutor = false; + } else { + this.flushExecutor = Executors.newSingleThreadExecutor( + daemonFactory("growthbook-tracking-plugin-flush")); + this.ownsFlushExecutor = true; + } + } + + public static GrowthBookTrackingPlugin of(TrackingPluginConfig config) { + if (config == null) { + throw new IllegalArgumentException("TrackingPluginConfig must not be null"); + } + return new GrowthBookTrackingPlugin(config); + } + + @Override + public void init() { + if (disabled) { + log.warn("GrowthBookTrackingPlugin disabled: clientKey is null or empty; events will not be sent."); + } + } + + @Override + public void onExperimentViewed(Experiment experiment, ExperimentResult result) { + if (disabled || closed.get()) return; + enqueue(TrackingEvent.forExperiment(experiment, result, null)); + } + + @Override + public void onExperimentViewed( + Experiment experiment, + ExperimentResult result, + EvaluationContext context + ) { + if (disabled || closed.get()) return; + enqueue(TrackingEvent.forExperiment(experiment, result, snapshotAttributes(context))); + } + + @Override + public void onFeatureEvaluated(String featureKey, FeatureResult result) { + if (disabled || closed.get()) return; + enqueue(TrackingEvent.forFeature(featureKey, result, null)); + } + + @Override + public void onFeatureEvaluated( + String featureKey, + FeatureResult result, + EvaluationContext context + ) { + if (disabled || closed.get()) return; + enqueue(TrackingEvent.forFeature(featureKey, result, snapshotAttributes(context))); + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + + ScheduledFuture pending; + List toFlush; + lock.lock(); + try { + pending = this.pendingFlush; + this.pendingFlush = null; + toFlush = drainLocked(); + } finally { + lock.unlock(); + } + + if (pending != null) { + pending.cancel(false); + } + + if (!toFlush.isEmpty()) { + try { + flushBatch(toFlush); + } catch (Throwable t) { + log.warn("Final tracking flush failed", t); + } + } + + scheduler.shutdownNow(); + if (ownsFlushExecutor && flushExecutor instanceof ExecutorService) { + ExecutorService es = (ExecutorService) flushExecutor; + es.shutdown(); + try { + if (!es.awaitTermination(config.resolvedCloseTimeout().toMillis(), TimeUnit.MILLISECONDS)) { + es.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + es.shutdownNow(); + } + } + if (ownsHttpClient) { + try { + httpClient.dispatcher().executorService().shutdown(); + httpClient.connectionPool().evictAll(); + } catch (Throwable t) { + log.debug("Failed to shut down owned OkHttpClient", t); + } + } + } + + // --- internal --- + + private void enqueue(TrackingEvent event) { + List eagerFlush = null; + lock.lock(); + try { + buffer.add(event); + int batchSize = config.resolvedBatchSize(); + if (buffer.size() >= batchSize) { + eagerFlush = drainLocked(); + if (pendingFlush != null) { + pendingFlush.cancel(false); + pendingFlush = null; + } + } else if (pendingFlush == null) { + Duration timeout = config.resolvedBatchTimeout(); + pendingFlush = scheduler.schedule(this::scheduledFlush, + timeout.toMillis(), TimeUnit.MILLISECONDS); + } + } finally { + lock.unlock(); + } + + if (eagerFlush != null && !eagerFlush.isEmpty()) { + submitFlush(eagerFlush); + } + } + + private void scheduledFlush() { + if (closed.get()) return; + List toFlush; + lock.lock(); + try { + pendingFlush = null; + toFlush = drainLocked(); + } finally { + lock.unlock(); + } + if (!toFlush.isEmpty()) { + submitFlush(toFlush); + } + } + + private List drainLocked() { + if (buffer.isEmpty()) return Collections.emptyList(); + List out = new ArrayList<>(buffer); + buffer.clear(); + return out; + } + + private void submitFlush(List batch) { + try { + flushExecutor.execute(() -> { + try { + flushBatch(batch); + } catch (Throwable t) { + log.warn("Tracking flush failed", t); + } + }); + } catch (RejectedExecutionException e) { + try { + flushBatch(batch); + } catch (Throwable t) { + log.warn("Inline tracking flush failed", t); + } + } + } + + private void flushBatch(List batch) { + if (batch.isEmpty() || disabled) return; + + JsonObject body = new JsonObject(); + body.addProperty("client_key", config.getClientKey()); + JsonArray events = new JsonArray(); + for (TrackingEvent e : batch) { + events.add(GrowthBookJsonUtils.getInstance().gson.toJsonTree(e)); + } + body.add("events", events); + + String url = config.resolvedIngestorHost() + "/events"; + Request request = new Request.Builder() + .url(url) + .header("User-Agent", SdkMetadata.USER_AGENT) + .post(RequestBody.create(body.toString(), JSON)) + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + log.warn("Tracking ingest POST {} returned status {}", url, response.code()); + } + } catch (IOException e) { + log.warn("Tracking ingest POST {} failed: {}", url, e.toString()); + } + } + + private static ThreadFactory daemonFactory(String prefix) { + return r -> { + Thread t = new Thread(r, prefix + "-" + THREAD_COUNTER.incrementAndGet()); + t.setDaemon(true); + return t; + }; + } + + @Nullable + private static JsonElement snapshotAttributes(@Nullable EvaluationContext context) { + if (context == null) return null; + UserContext user = context.getUser(); + if (user == null || user.getAttributes() == null) return null; + return user.getAttributes().deepCopy(); + } +} diff --git a/lib/src/main/java/growthbook/sdk/java/plugin/tracking/SdkMetadata.java b/lib/src/main/java/growthbook/sdk/java/plugin/tracking/SdkMetadata.java new file mode 100644 index 00000000..3b02b6c1 --- /dev/null +++ b/lib/src/main/java/growthbook/sdk/java/plugin/tracking/SdkMetadata.java @@ -0,0 +1,29 @@ +package growthbook.sdk.java.plugin.tracking; + +import growthbook.sdk.java.Version; + +/** + * Identifying metadata for this SDK. Emitted on every tracked event and in + * the {@code User-Agent} of outbound ingest requests. + */ +public final class SdkMetadata { + + public static final String LANGUAGE = "java"; + + public static final String NAME = "growthbook-java-sdk"; + + public static final String VERSION = resolveVersion(); + + public static final String USER_AGENT = NAME + "/" + VERSION; + + private SdkMetadata() { + } + + private static String resolveVersion() { + String implVersion = SdkMetadata.class.getPackage().getImplementationVersion(); + if (implVersion != null && !implVersion.isEmpty()) { + return implVersion; + } + return Version.SDK_VERSION; + } +} diff --git a/lib/src/main/java/growthbook/sdk/java/plugin/tracking/TrackingEvent.java b/lib/src/main/java/growthbook/sdk/java/plugin/tracking/TrackingEvent.java new file mode 100644 index 00000000..9bc56421 --- /dev/null +++ b/lib/src/main/java/growthbook/sdk/java/plugin/tracking/TrackingEvent.java @@ -0,0 +1,185 @@ +package growthbook.sdk.java.plugin.tracking; + +import com.google.gson.JsonElement; +import com.google.gson.annotations.SerializedName; +import growthbook.sdk.java.model.Experiment; +import growthbook.sdk.java.model.ExperimentResult; +import growthbook.sdk.java.model.FeatureResult; +import growthbook.sdk.java.util.GrowthBookJsonUtils; + +import javax.annotation.Nullable; +import java.time.Instant; + +/** + * A single event dispatched by {@link GrowthBookTrackingPlugin} to the + * GrowthBook data-warehouse ingest endpoint. Field names mirror the Go SDK. + */ +public final class TrackingEvent { + + public static final String EVENT_EXPERIMENT_VIEWED = "experiment_viewed"; + public static final String EVENT_FEATURE_EVALUATED = "feature_evaluated"; + + @SerializedName("event_type") + private final String eventType; + + @SerializedName("timestamp") + private final String timestamp; + + @SerializedName("sdk_language") + private final String sdkLanguage = SdkMetadata.LANGUAGE; + + @SerializedName("sdk_version") + private final String sdkVersion = SdkMetadata.VERSION; + + @Nullable + @SerializedName("attributes") + private final JsonElement attributes; + + @Nullable + @SerializedName("experiment_key") + private final String experimentKey; + + @Nullable + @SerializedName("variation_id") + private final Integer variationId; + + @Nullable + @SerializedName("variation_key") + private final String variationKey; + + @Nullable + @SerializedName("hash_attribute") + private final String hashAttribute; + + @Nullable + @SerializedName("hash_value") + private final String hashValue; + + @Nullable + @SerializedName("feature_key") + private final String featureKey; + + @Nullable + @SerializedName("feature_source") + private final String featureSource; + + @Nullable + @SerializedName("rule_id") + private final String ruleId; + + @Nullable + @SerializedName("value") + private final JsonElement value; + + private TrackingEvent(Builder b) { + this.eventType = b.eventType; + this.timestamp = b.timestamp != null ? b.timestamp : Instant.now().toString(); + this.attributes = b.attributes; + this.experimentKey = b.experimentKey; + this.variationId = b.variationId; + this.variationKey = b.variationKey; + this.hashAttribute = b.hashAttribute; + this.hashValue = b.hashValue; + this.featureKey = b.featureKey; + this.featureSource = b.featureSource; + this.ruleId = b.ruleId; + this.value = b.value; + } + + public String getEventType() { + return eventType; + } + + public static TrackingEvent forExperiment( + Experiment experiment, + ExperimentResult result, + @Nullable JsonElement attributes + ) { + return new Builder() + .eventType(EVENT_EXPERIMENT_VIEWED) + .attributes(attributes) + .experimentKey(experiment != null ? experiment.getKey() : null) + .variationId(result != null ? result.getVariationId() : null) + .variationKey(result != null ? result.getKey() : null) + .hashAttribute(result != null ? result.getHashAttribute() : null) + .hashValue(result != null ? result.getHashValue() : null) + .value(result != null ? toJson(result.getValue()) : null) + .build(); + } + + public static TrackingEvent forFeature( + String featureKey, + FeatureResult result, + @Nullable JsonElement attributes + ) { + String source = null; + String ruleId = null; + String experimentKey = null; + Integer variationId = null; + Object value = null; + if (result != null) { + if (result.getSource() != null) { + source = result.getSource().toString(); + } + ruleId = result.getRuleId(); + value = result.getValue(); + Experiment exp = result.getExperiment(); + ExperimentResult expResult = result.getExperimentResult(); + if (exp != null) { + experimentKey = exp.getKey(); + } + if (expResult != null) { + variationId = expResult.getVariationId(); + } + } + return new Builder() + .eventType(EVENT_FEATURE_EVALUATED) + .attributes(attributes) + .featureKey(featureKey) + .featureSource(source) + .ruleId(ruleId) + .experimentKey(experimentKey) + .variationId(variationId) + .value(toJson(value)) + .build(); + } + + @Nullable + private static JsonElement toJson(@Nullable Object value) { + if (value == null) return null; + try { + return GrowthBookJsonUtils.getInstance().gson.toJsonTree(value); + } catch (Throwable t) { + return null; + } + } + + static final class Builder { + private String eventType; + private String timestamp; + private JsonElement attributes; + private String experimentKey; + private Integer variationId; + private String variationKey; + private String hashAttribute; + private String hashValue; + private String featureKey; + private String featureSource; + private String ruleId; + private JsonElement value; + + Builder eventType(String v) { this.eventType = v; return this; } + Builder attributes(JsonElement v) { this.attributes = v; return this; } + Builder experimentKey(String v) { this.experimentKey = v; return this; } + Builder variationId(Integer v) { this.variationId = v; return this; } + Builder variationKey(String v) { this.variationKey = v; return this; } + Builder hashAttribute(String v) { this.hashAttribute = v; return this; } + Builder hashValue(String v) { this.hashValue = v; return this; } + Builder featureKey(String v) { this.featureKey = v; return this; } + Builder featureSource(String v) { this.featureSource = v; return this; } + Builder ruleId(String v) { this.ruleId = v; return this; } + Builder value(JsonElement v) { this.value = v; return this; } + + TrackingEvent build() { return new TrackingEvent(this); } + } +} diff --git a/lib/src/main/java/growthbook/sdk/java/plugin/tracking/TrackingPluginConfig.java b/lib/src/main/java/growthbook/sdk/java/plugin/tracking/TrackingPluginConfig.java new file mode 100644 index 00000000..be8113ca --- /dev/null +++ b/lib/src/main/java/growthbook/sdk/java/plugin/tracking/TrackingPluginConfig.java @@ -0,0 +1,82 @@ +package growthbook.sdk.java.plugin.tracking; + +import lombok.Builder; +import lombok.Getter; +import okhttp3.OkHttpClient; + +import javax.annotation.Nullable; +import java.time.Duration; +import java.util.concurrent.Executor; + +/** + * Configuration for {@link GrowthBookTrackingPlugin}. Defaults mirror the + * GrowthBook Go SDK: POST {@code {ingestorHost}/events}, batch size 100, + * flush every 10 seconds. + */ +@Getter +@Builder +public final class TrackingPluginConfig { + + public static final String DEFAULT_INGESTOR_HOST = "https://us1.gb-ingest.com"; + public static final int DEFAULT_BATCH_SIZE = 100; + public static final Duration DEFAULT_BATCH_TIMEOUT = Duration.ofSeconds(10); + public static final Duration DEFAULT_CLOSE_TIMEOUT = Duration.ofSeconds(5); + + /** Base URL of the ingest endpoint. Events POST to {@code /events}. */ + @Nullable + private final String ingestorHost; + + /** + * Client key (SDK connection key). If null/empty the plugin becomes a + * no-op — it will not make HTTP requests but {@link GrowthBookTrackingPlugin#close()} + * still completes cleanly. + */ + @Nullable + private final String clientKey; + + /** Max events buffered before an eager flush. */ + @Nullable + private final Integer batchSize; + + /** Max time an event sits in the buffer before a scheduled flush. */ + @Nullable + private final Duration batchTimeout; + + /** Max time {@link GrowthBookTrackingPlugin#close()} waits for the final flush. */ + @Nullable + private final Duration closeTimeout; + + /** Optional HTTP client override. Defaults to a fresh OkHttpClient. */ + @Nullable + private final OkHttpClient httpClient; + + /** + * Optional executor used to POST batches off the caller thread. Defaults + * to a single-thread daemon executor created by the plugin. + */ + @Nullable + private final Executor flushExecutor; + + public String resolvedIngestorHost() { + if (ingestorHost == null || ingestorHost.isEmpty()) return DEFAULT_INGESTOR_HOST; + return stripTrailingSlash(ingestorHost); + } + + public int resolvedBatchSize() { + return batchSize == null || batchSize <= 0 ? DEFAULT_BATCH_SIZE : batchSize; + } + + public Duration resolvedBatchTimeout() { + return batchTimeout == null || batchTimeout.isZero() || batchTimeout.isNegative() + ? DEFAULT_BATCH_TIMEOUT : batchTimeout; + } + + public Duration resolvedCloseTimeout() { + return closeTimeout == null || closeTimeout.isZero() || closeTimeout.isNegative() + ? DEFAULT_CLOSE_TIMEOUT : closeTimeout; + } + + private static String stripTrailingSlash(String s) { + return s.endsWith("/") ? s.substring(0, s.length() - 1) : s; + } +} diff --git a/lib/src/test/java/growthbook/sdk/java/GBContextTest.java b/lib/src/test/java/growthbook/sdk/java/GBContextTest.java index 498b1ff2..98970e13 100644 --- a/lib/src/test/java/growthbook/sdk/java/GBContextTest.java +++ b/lib/src/test/java/growthbook/sdk/java/GBContextTest.java @@ -66,6 +66,7 @@ void canBeConstructed() { null, null, null, + null, null ); @@ -151,6 +152,7 @@ void supportsEncryptedFeaturesUsingConstructor() { null, null, null, + null, null ); String expectedFeaturesJson = "{\"greeting\":{\"defaultValue\":\"hello\",\"rules\":[{\"condition\":{\"country\":\"france\"},\"force\":\"bonjour\"},{\"condition\":{\"country\":\"mexico\"},\"force\":\"hola\"}]}}"; diff --git a/lib/src/test/java/growthbook/sdk/java/plugin/GrowthBookClientPluginIntegrationTest.java b/lib/src/test/java/growthbook/sdk/java/plugin/GrowthBookClientPluginIntegrationTest.java new file mode 100644 index 00000000..50c83d99 --- /dev/null +++ b/lib/src/test/java/growthbook/sdk/java/plugin/GrowthBookClientPluginIntegrationTest.java @@ -0,0 +1,61 @@ +package growthbook.sdk.java.plugin; + +import growthbook.sdk.java.model.Experiment; +import growthbook.sdk.java.model.ExperimentResult; +import growthbook.sdk.java.model.FeatureResult; +import growthbook.sdk.java.multiusermode.GrowthBookClient; +import growthbook.sdk.java.multiusermode.configurations.Options; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Multi-user mode plugin lifecycle wiring test. Avoids the feature repository + * by never calling {@link GrowthBookClient#initialize()}; verifies the + * registry is built, stashed on {@link Options}, and flushed on close. + */ +class GrowthBookClientPluginIntegrationTest { + + @Test + void registryIsBuiltAndFlushedOnClose() { + AtomicInteger inits = new AtomicInteger(); + AtomicInteger closed = new AtomicInteger(); + + GrowthBookPlugin plugin = new GrowthBookPlugin() { + @Override public void init() { inits.incrementAndGet(); } + @Override public void onExperimentViewed(Experiment e, ExperimentResult r) {} + @Override public void onFeatureEvaluated(String k, FeatureResult r) {} + @Override public void close() { closed.incrementAndGet(); } + }; + + Options options = Options.builder() + .plugins(Collections.singletonList(plugin)) + .build(); + GrowthBookClient client = new GrowthBookClient(options); + + assertNotNull(options.getPluginRegistry(), "registry should be stashed on options"); + assertEquals(1, inits.get(), "plugin init() should fire on construction"); + + client.close(); + + assertEquals(1, closed.get(), "plugin close() should fire on client.close()"); + } + + @Test + void tripleCloseIsSafe() { + GrowthBookPlugin plugin = new GrowthBookPlugin() {}; + Options options = Options.builder() + .plugins(Collections.singletonList(plugin)) + .build(); + GrowthBookClient client = new GrowthBookClient(options); + client.close(); + client.close(); + client.shutdown(); + assertSame(options, options, "no exception on repeated close/shutdown"); + } +} diff --git a/lib/src/test/java/growthbook/sdk/java/plugin/PluginIntegrationTest.java b/lib/src/test/java/growthbook/sdk/java/plugin/PluginIntegrationTest.java new file mode 100644 index 00000000..9ba81a69 --- /dev/null +++ b/lib/src/test/java/growthbook/sdk/java/plugin/PluginIntegrationTest.java @@ -0,0 +1,147 @@ +package growthbook.sdk.java.plugin; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import growthbook.sdk.java.GrowthBook; +import growthbook.sdk.java.callback.FeatureUsageCallback; +import growthbook.sdk.java.callback.TrackingCallback; +import growthbook.sdk.java.model.Experiment; +import growthbook.sdk.java.model.ExperimentResult; +import growthbook.sdk.java.model.Feature; +import growthbook.sdk.java.model.FeatureResult; +import growthbook.sdk.java.model.GBContext; +import growthbook.sdk.java.multiusermode.configurations.EvaluationContext; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end: plugins observe events from real {@link GrowthBook} evaluations, + * and an existing {@link TrackingCallback}/{@link FeatureUsageCallback} still fires. + */ +class PluginIntegrationTest { + + @Test + void pluginsObserveFeatureAndExperimentEvents() { + List> featureSeen = Collections.synchronizedList(new ArrayList<>()); + List> experimentSeen = Collections.synchronizedList(new ArrayList<>()); + AtomicInteger closed = new AtomicInteger(); + + GrowthBookPlugin plugin = new GrowthBookPlugin() { + @Override public void onExperimentViewed(Experiment e, ExperimentResult r) { experimentSeen.add(r); } + @Override public void onFeatureEvaluated(String k, FeatureResult r) { featureSeen.add(r); } + @Override public void close() { closed.incrementAndGet(); } + }; + + AtomicInteger trackingCallbackCalls = new AtomicInteger(); + AtomicInteger featureCallbackCalls = new AtomicInteger(); + TrackingCallback tc = new TrackingCallback() { + @Override public void onTrack(Experiment experiment, ExperimentResult result) { + trackingCallbackCalls.incrementAndGet(); + } + }; + FeatureUsageCallback fc = new FeatureUsageCallback() { + @Override public void onFeatureUsage(String key, FeatureResult result) { + featureCallbackCalls.incrementAndGet(); + } + }; + + String featuresJson = "{\"flag-a\": {\"defaultValue\": true}, \"flag-b\": {\"defaultValue\": \"x\"}}"; + GBContext ctx = GBContext.builder() + .featuresJson(featuresJson) + .attributesJson("{\"id\":\"u1\"}") + .trackingCallback(tc) + .featureUsageCallback(fc) + .plugins(Collections.singletonList(plugin)) + .build(); + GrowthBook gb = new GrowthBook(ctx); + + gb.isOn("flag-a"); + gb.evalFeature("flag-b", String.class); + + // Experiment with two variations; default id attribute is present, so it should be in-experiment. + Experiment exp = Experiment.builder() + .key("my-exp") + .variations(new ArrayList<>(Arrays.asList("A", "B"))) + .build(); + ExperimentResult result = gb.run(exp); + + gb.close(); + + assertTrue(featureSeen.size() >= 2, "plugin should have seen at least 2 feature evaluations"); + assertTrue(featureCallbackCalls.get() >= 2, "existing feature usage callback should still fire"); + + if (Boolean.TRUE.equals(result.getInExperiment())) { + assertEquals(1, experimentSeen.size(), "plugin should have seen the experiment event exactly once"); + assertEquals(1, trackingCallbackCalls.get(), "existing tracking callback should still fire"); + } + + assertEquals(1, closed.get(), "plugin close() should fire when GrowthBook.close() is called"); + } + + @Test + void pluginReceivesFeatureEventEvenWithoutExistingCallback() { + List keys = new ArrayList<>(); + GrowthBookPlugin plugin = new GrowthBookPlugin() { + @Override public void onFeatureEvaluated(String k, FeatureResult r) { keys.add(k); } + }; + JsonObject attrs = JsonParser.parseString("{\"id\":\"u1\"}").getAsJsonObject(); + GBContext ctx = GBContext.builder() + .featuresJson("{\"flag\": {\"defaultValue\": 42}}") + .attributes(attrs) + .plugins(Collections.singletonList(plugin)) + .build(); + GrowthBook gb = new GrowthBook(ctx); + + FeatureResult r = gb.evalFeature("flag", Integer.class); + assertNotNull(r); + assertTrue(keys.contains("flag")); + gb.close(); + } + + @Test + void contextAwarePluginReceivesAttributesForOwningInstanceOnly() { + List firstInstanceIds = new ArrayList<>(); + List secondInstanceIds = new ArrayList<>(); + + GrowthBookPlugin firstPlugin = new GrowthBookPlugin() { + @Override + public void onFeatureEvaluated(String k, FeatureResult r, EvaluationContext context) { + firstInstanceIds.add(context.getUser().getAttributes().get("id").getAsString()); + } + }; + GrowthBookPlugin secondPlugin = new GrowthBookPlugin() { + @Override + public void onFeatureEvaluated(String k, FeatureResult r, EvaluationContext context) { + secondInstanceIds.add(context.getUser().getAttributes().get("id").getAsString()); + } + }; + + GrowthBook first = new GrowthBook(GBContext.builder() + .featuresJson("{\"flag\": {\"defaultValue\": true}}") + .attributesJson("{\"id\":\"first\"}") + .plugins(Collections.singletonList(firstPlugin)) + .build()); + GrowthBook second = new GrowthBook(GBContext.builder() + .featuresJson("{\"flag\": {\"defaultValue\": true}}") + .attributesJson("{\"id\":\"second\"}") + .plugins(Collections.singletonList(secondPlugin)) + .build()); + + first.isOn("flag"); + second.isOn("flag"); + first.close(); + second.close(); + + assertEquals(Collections.singletonList("first"), firstInstanceIds); + assertEquals(Collections.singletonList("second"), secondInstanceIds); + } +} diff --git a/lib/src/test/java/growthbook/sdk/java/plugin/PluginRegistryTest.java b/lib/src/test/java/growthbook/sdk/java/plugin/PluginRegistryTest.java new file mode 100644 index 00000000..ca1c3557 --- /dev/null +++ b/lib/src/test/java/growthbook/sdk/java/plugin/PluginRegistryTest.java @@ -0,0 +1,82 @@ +package growthbook.sdk.java.plugin; + +import growthbook.sdk.java.model.Experiment; +import growthbook.sdk.java.model.ExperimentResult; +import growthbook.sdk.java.model.FeatureResult; +import growthbook.sdk.java.model.FeatureResultSource; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PluginRegistryTest { + + @Test + void dispatchesToEachPlugin() { + AtomicInteger exp = new AtomicInteger(); + AtomicInteger feat = new AtomicInteger(); + AtomicInteger inits = new AtomicInteger(); + AtomicInteger closes = new AtomicInteger(); + + GrowthBookPlugin a = new GrowthBookPlugin() { + @Override public void init() { inits.incrementAndGet(); } + @Override public void onExperimentViewed(Experiment e, ExperimentResult r) { exp.incrementAndGet(); } + @Override public void onFeatureEvaluated(String k, FeatureResult r) { feat.incrementAndGet(); } + @Override public void close() { closes.incrementAndGet(); } + }; + GrowthBookPlugin b = new GrowthBookPlugin() { + @Override public void init() { inits.incrementAndGet(); } + @Override public void onExperimentViewed(Experiment e, ExperimentResult r) { exp.incrementAndGet(); } + @Override public void onFeatureEvaluated(String k, FeatureResult r) { feat.incrementAndGet(); } + @Override public void close() { closes.incrementAndGet(); } + }; + + PluginRegistry registry = new PluginRegistry(Arrays.asList(a, b)); + registry.initAll(); + registry.fireExperimentViewed(Experiment.builder().key("e").build(), ExperimentResult.builder().build()); + registry.fireFeatureEvaluated("f", FeatureResult.builder().source(FeatureResultSource.DEFAULT_VALUE).build()); + registry.closeAll(); + + assertEquals(2, inits.get()); + assertEquals(2, exp.get()); + assertEquals(2, feat.get()); + assertEquals(2, closes.get()); + } + + @Test + void onePluginThrowingDoesNotStopOthers() { + AtomicInteger calls = new AtomicInteger(); + GrowthBookPlugin bad = new GrowthBookPlugin() { + @Override public void init() { throw new RuntimeException("boom"); } + @Override public void onExperimentViewed(Experiment e, ExperimentResult r) { throw new RuntimeException("boom"); } + @Override public void onFeatureEvaluated(String k, FeatureResult r) { throw new RuntimeException("boom"); } + @Override public void close() { throw new RuntimeException("boom"); } + }; + GrowthBookPlugin good = new GrowthBookPlugin() { + @Override public void init() { calls.incrementAndGet(); } + @Override public void onExperimentViewed(Experiment e, ExperimentResult r) { calls.incrementAndGet(); } + @Override public void onFeatureEvaluated(String k, FeatureResult r) { calls.incrementAndGet(); } + @Override public void close() { calls.incrementAndGet(); } + }; + + PluginRegistry registry = new PluginRegistry(Arrays.asList(bad, good)); + registry.initAll(); + registry.fireExperimentViewed(Experiment.builder().key("e").build(), ExperimentResult.builder().build()); + registry.fireFeatureEvaluated("f", FeatureResult.builder().build()); + registry.closeAll(); + + assertEquals(4, calls.get(), "good plugin should have received all 4 lifecycle events"); + } + + @Test + void emptyRegistryIsNoOp() { + PluginRegistry registry = new PluginRegistry(Collections.emptyList()); + registry.initAll(); + registry.fireExperimentViewed(Experiment.builder().key("e").build(), ExperimentResult.builder().build()); + registry.fireFeatureEvaluated("f", FeatureResult.builder().build()); + registry.closeAll(); + } +} diff --git a/lib/src/test/java/growthbook/sdk/java/plugin/tracking/GrowthBookTrackingPluginTest.java b/lib/src/test/java/growthbook/sdk/java/plugin/tracking/GrowthBookTrackingPluginTest.java new file mode 100644 index 00000000..e131f5f6 --- /dev/null +++ b/lib/src/test/java/growthbook/sdk/java/plugin/tracking/GrowthBookTrackingPluginTest.java @@ -0,0 +1,320 @@ +package growthbook.sdk.java.plugin.tracking; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import growthbook.sdk.java.GrowthBook; +import growthbook.sdk.java.model.Experiment; +import growthbook.sdk.java.model.ExperimentResult; +import growthbook.sdk.java.model.FeatureResult; +import growthbook.sdk.java.model.FeatureResultSource; +import growthbook.sdk.java.model.GBContext; +import growthbook.sdk.java.multiusermode.GrowthBookClient; +import growthbook.sdk.java.multiusermode.configurations.EvaluationContext; +import growthbook.sdk.java.multiusermode.configurations.GlobalContext; +import growthbook.sdk.java.multiusermode.configurations.Options; +import growthbook.sdk.java.multiusermode.configurations.UserContext; +import growthbook.sdk.java.multiusermode.util.TransformationUtil; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GrowthBookTrackingPluginTest { + + private MockWebServer server; + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws Exception { + server.shutdown(); + } + + private TrackingPluginConfig.TrackingPluginConfigBuilder configBuilder() { + return TrackingPluginConfig.builder() + .ingestorHost(server.url("/").toString()) + .clientKey("sdk-test"); + } + + private static Experiment experiment(String key) { + return Experiment.builder().key(key).build(); + } + + private static ExperimentResult experimentResult(int variation) { + return ExperimentResult.builder() + .variationId(variation) + .hashAttribute("id") + .hashValue("u-" + variation) + .build(); + } + + private static FeatureResult featureResult(FeatureResultSource source) { + return FeatureResult.builder().source(source).build(); + } + + private static EvaluationContext evalContext(JsonObject attributes) { + return new EvaluationContext( + GlobalContext.builder().build(), + UserContext.builder().attributes(attributes).build(), + new EvaluationContext.StackContext(), + Options.builder().build() + ); + } + + private static void setGlobalContext(GrowthBookClient client, String featuresJson) throws Exception { + Field globalContext = GrowthBookClient.class.getDeclaredField("globalContext"); + globalContext.setAccessible(true); + globalContext.set(client, GlobalContext.builder() + .features(TransformationUtil.transformFeatures(featuresJson)) + .enabled(true) + .build()); + } + + @Test + void flushesWhenBatchSizeReached() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + GrowthBookTrackingPlugin plugin = GrowthBookTrackingPlugin.of(configBuilder() + .batchSize(2) + .batchTimeout(Duration.ofSeconds(30)) + .build()); + plugin.init(); + + plugin.onExperimentViewed(experiment("exp1"), experimentResult(0)); + plugin.onExperimentViewed(experiment("exp2"), experimentResult(1)); + + RecordedRequest req = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(req, "should have flushed on batch size threshold"); + assertEquals("POST", req.getMethod()); + assertEquals("/events", req.getPath()); + assertTrue(req.getHeader("User-Agent").startsWith("growthbook-java-sdk/")); + assertEquals("application/json; charset=utf-8", req.getHeader("Content-Type")); + + JsonObject body = JsonParser.parseString(req.getBody().readUtf8()).getAsJsonObject(); + assertEquals("sdk-test", body.get("client_key").getAsString()); + JsonArray events = body.getAsJsonArray("events"); + assertEquals(2, events.size()); + assertEquals("experiment_viewed", events.get(0).getAsJsonObject().get("event_type").getAsString()); + assertEquals("exp1", events.get(0).getAsJsonObject().get("experiment_key").getAsString()); + + plugin.close(); + } + + @Test + void flushesWhenTimerFires() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + GrowthBookTrackingPlugin plugin = GrowthBookTrackingPlugin.of(configBuilder() + .batchSize(100) + .batchTimeout(Duration.ofMillis(200)) + .build()); + plugin.init(); + + plugin.onFeatureEvaluated("flag1", featureResult(FeatureResultSource.DEFAULT_VALUE)); + + RecordedRequest req = server.takeRequest(3, TimeUnit.SECONDS); + assertNotNull(req, "timer-based flush should fire within 3s"); + JsonObject body = JsonParser.parseString(req.getBody().readUtf8()).getAsJsonObject(); + JsonArray events = body.getAsJsonArray("events"); + assertEquals(1, events.size()); + assertEquals("feature_evaluated", events.get(0).getAsJsonObject().get("event_type").getAsString()); + assertEquals("flag1", events.get(0).getAsJsonObject().get("feature_key").getAsString()); + + plugin.close(); + } + + @Test + void closeFlushesRemainingEvents() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + GrowthBookTrackingPlugin plugin = GrowthBookTrackingPlugin.of(configBuilder() + .batchSize(100) + .batchTimeout(Duration.ofSeconds(60)) + .build()); + plugin.init(); + + plugin.onExperimentViewed(experiment("exp"), experimentResult(0)); + plugin.close(); + + RecordedRequest req = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(req, "close() should flush the final batch synchronously"); + JsonObject body = JsonParser.parseString(req.getBody().readUtf8()).getAsJsonObject(); + assertEquals(1, body.getAsJsonArray("events").size()); + } + + @Test + void closeIsIdempotent() throws Exception { + GrowthBookTrackingPlugin plugin = GrowthBookTrackingPlugin.of(configBuilder().build()); + plugin.close(); + plugin.close(); + } + + @Test + void noClientKeyDisablesPlugin() throws Exception { + GrowthBookTrackingPlugin plugin = GrowthBookTrackingPlugin.of(TrackingPluginConfig.builder() + .ingestorHost(server.url("/").toString()) + .batchSize(1) + .build()); + plugin.init(); + + plugin.onExperimentViewed(experiment("exp"), experimentResult(0)); + plugin.onFeatureEvaluated("flag", featureResult(FeatureResultSource.DEFAULT_VALUE)); + plugin.close(); + + assertNull(server.takeRequest(500, TimeUnit.MILLISECONDS), + "disabled plugin must not hit the network"); + } + + @Test + void httpFailureDoesNotThrow() throws Exception { + server.enqueue(new MockResponse().setResponseCode(500)); + + GrowthBookTrackingPlugin plugin = GrowthBookTrackingPlugin.of(configBuilder() + .batchSize(1) + .build()); + plugin.init(); + + // Must not throw despite the 500. + plugin.onExperimentViewed(experiment("exp"), experimentResult(0)); + + RecordedRequest req = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(req); + plugin.close(); + } + + @Test + void ingestorHostTrailingSlashStripped() { + TrackingPluginConfig cfg = TrackingPluginConfig.builder() + .ingestorHost("https://example.test/") + .clientKey("k") + .build(); + assertEquals("https://example.test", cfg.resolvedIngestorHost()); + assertFalse(cfg.resolvedIngestorHost().endsWith("/")); + } + + @Test + void contextAttributesAreSnapshottedWhenEventIsBuffered() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + GrowthBookTrackingPlugin plugin = GrowthBookTrackingPlugin.of(configBuilder() + .batchSize(100) + .batchTimeout(Duration.ofSeconds(60)) + .build()); + plugin.init(); + + JsonObject attrs = JsonParser.parseString("{\"id\":\"u1\",\"plan\":\"pro\"}").getAsJsonObject(); + plugin.onFeatureEvaluated("flag", featureResult(FeatureResultSource.DEFAULT_VALUE), evalContext(attrs)); + attrs.addProperty("id", "mutated"); + plugin.close(); + + RecordedRequest req = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(req); + JsonObject event = JsonParser.parseString(req.getBody().readUtf8()) + .getAsJsonObject() + .getAsJsonArray("events") + .get(0) + .getAsJsonObject(); + assertEquals("u1", event.getAsJsonObject("attributes").get("id").getAsString()); + assertEquals("pro", event.getAsJsonObject("attributes").get("plan").getAsString()); + } + + @Test + void singleUserGrowthBookTrackingIncludesContextAttributes() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + GrowthBookTrackingPlugin plugin = GrowthBookTrackingPlugin.of(configBuilder() + .batchSize(100) + .batchTimeout(Duration.ofSeconds(60)) + .build()); + GBContext context = GBContext.builder() + .featuresJson("{\"flag\":{\"defaultValue\":true}}") + .attributesJson("{\"id\":\"single-user\",\"tier\":\"gold\"}") + .plugins(Collections.singletonList(plugin)) + .build(); + GrowthBook growthBook = new GrowthBook(context); + + growthBook.evalFeature("flag", Boolean.class); + growthBook.close(); + + RecordedRequest req = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(req); + JsonObject event = JsonParser.parseString(req.getBody().readUtf8()) + .getAsJsonObject() + .getAsJsonArray("events") + .get(0) + .getAsJsonObject(); + assertEquals("single-user", event.getAsJsonObject("attributes").get("id").getAsString()); + assertEquals("gold", event.getAsJsonObject("attributes").get("tier").getAsString()); + } + + @Test + void multiUserGrowthBookClientTrackingUsesMergedScopedAttributes() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + GrowthBookTrackingPlugin plugin = GrowthBookTrackingPlugin.of(configBuilder() + .batchSize(100) + .batchTimeout(Duration.ofSeconds(60)) + .build()); + Options options = Options.builder() + .globalAttributes(JsonParser.parseString("{\"company\":\"acme\",\"id\":\"global\"}").getAsJsonObject()) + .plugins(Collections.singletonList(plugin)) + .build(); + GrowthBookClient client = new GrowthBookClient(options); + setGlobalContext(client, "{\"flag\":{\"defaultValue\":true}}"); + + client.evalFeature("flag", Boolean.class, + UserContext.builder().attributesJson("{\"id\":\"u1\"}").build()); + client.evalFeature("flag", Boolean.class, + UserContext.builder().attributesJson("{\"id\":\"u2\"}").build()); + client.close(); + + RecordedRequest req = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(req); + JsonArray events = JsonParser.parseString(req.getBody().readUtf8()) + .getAsJsonObject() + .getAsJsonArray("events"); + assertEquals(2, events.size()); + JsonObject firstAttributes = events.get(0).getAsJsonObject().getAsJsonObject("attributes"); + JsonObject secondAttributes = events.get(1).getAsJsonObject().getAsJsonObject("attributes"); + assertEquals("acme", firstAttributes.get("company").getAsString()); + assertEquals("u1", firstAttributes.get("id").getAsString()); + assertEquals("acme", secondAttributes.get("company").getAsString()); + assertEquals("u2", secondAttributes.get("id").getAsString()); + } + + @Test + void userAgentDoesNotUseUnknownVersionFallback() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + GrowthBookTrackingPlugin plugin = GrowthBookTrackingPlugin.of(configBuilder() + .batchSize(1) + .build()); + plugin.init(); + plugin.onFeatureEvaluated("flag", featureResult(FeatureResultSource.DEFAULT_VALUE)); + + RecordedRequest req = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(req); + assertFalse(req.getHeader("User-Agent").endsWith("/unknown")); + assertFalse(SdkMetadata.VERSION.isEmpty()); + assertFalse("unknown".equals(SdkMetadata.VERSION)); + plugin.close(); + } +} diff --git a/lib/src/test/java/growthbook/sdk/java/plugin/tracking/TrackingPluginSmokeHarness.java b/lib/src/test/java/growthbook/sdk/java/plugin/tracking/TrackingPluginSmokeHarness.java new file mode 100644 index 00000000..77e951c5 --- /dev/null +++ b/lib/src/test/java/growthbook/sdk/java/plugin/tracking/TrackingPluginSmokeHarness.java @@ -0,0 +1,166 @@ +package growthbook.sdk.java.plugin.tracking; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import growthbook.sdk.java.GrowthBook; +import growthbook.sdk.java.model.Experiment; +import growthbook.sdk.java.model.GBContext; +import growthbook.sdk.java.multiusermode.GrowthBookClient; +import growthbook.sdk.java.multiusermode.configurations.GlobalContext; +import growthbook.sdk.java.multiusermode.configurations.Options; +import growthbook.sdk.java.multiusermode.configurations.UserContext; +import growthbook.sdk.java.multiusermode.util.TransformationUtil; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +/** + * Local smoke harness for inspecting the real tracking plugin HTTP payload. + * + *

Run with {@code ./gradlew :lib:runTrackingPluginSmoke}. Set + * {@code -PtrackingSmokeMode=single|multi|both} to narrow the scenario. + */ +public final class TrackingPluginSmokeHarness { + + private static final String FEATURES_JSON = "{\"flag\":{\"defaultValue\":true}}"; + + private TrackingPluginSmokeHarness() { + } + + public static void main(String[] args) throws Exception { + String mode = System.getProperty("trackingSmokeMode", "both"); + try (MockWebServer server = new MockWebServer()) { + server.start(); + if ("single".equals(mode) || "both".equals(mode)) { + JsonObject body = runSingleUserScenario(server); + printScenario("single", body); + } + if ("multi".equals(mode) || "both".equals(mode)) { + JsonObject body = runMultiUserScenario(server); + printScenario("multi", body); + } + if (!"single".equals(mode) && !"multi".equals(mode) && !"both".equals(mode)) { + throw new IllegalArgumentException("trackingSmokeMode must be single, multi, or both"); + } + } + } + + private static JsonObject runSingleUserScenario(MockWebServer server) throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + GrowthBookTrackingPlugin plugin = GrowthBookTrackingPlugin.of(config(server)); + + GBContext context = GBContext.builder() + .featuresJson(FEATURES_JSON) + .attributesJson("{\"id\":\"single-smoke\",\"tier\":\"gold\"}") + .plugins(Collections.singletonList(plugin)) + .build(); + GrowthBook growthBook = new GrowthBook(context); + growthBook.evalFeature("flag", Boolean.class); + growthBook.run(Experiment.builder() + .key("smoke-experiment") + .variations(new ArrayList<>(Arrays.asList("A", "B"))) + .coverage(1f) + .build()); + growthBook.close(); + + JsonObject body = readBody(server); + requireAttribute(body, 0, "id", "single-smoke"); + requireHeaderShape(body); + return body; + } + + private static JsonObject runMultiUserScenario(MockWebServer server) throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + GrowthBookTrackingPlugin plugin = GrowthBookTrackingPlugin.of(config(server)); + + Options options = Options.builder() + .globalAttributes(JsonParser.parseString("{\"company\":\"acme\"}").getAsJsonObject()) + .plugins(Collections.singletonList(plugin)) + .build(); + GrowthBookClient client = new GrowthBookClient(options); + setGlobalContext(client); + + client.evalFeature("flag", Boolean.class, + UserContext.builder().attributesJson("{\"id\":\"multi-smoke-1\"}").build()); + client.evalFeature("flag", Boolean.class, + UserContext.builder().attributesJson("{\"id\":\"multi-smoke-2\"}").build()); + client.close(); + + JsonObject body = readBody(server); + requireAttribute(body, 0, "company", "acme"); + requireAttribute(body, 0, "id", "multi-smoke-1"); + requireAttribute(body, 1, "id", "multi-smoke-2"); + requireHeaderShape(body); + return body; + } + + private static TrackingPluginConfig config(MockWebServer server) { + return TrackingPluginConfig.builder() + .ingestorHost(server.url("/").toString()) + .clientKey("sdk-smoke") + .batchSize(100) + .batchTimeout(Duration.ofSeconds(60)) + .build(); + } + + private static JsonObject readBody(MockWebServer server) throws Exception { + RecordedRequest request = server.takeRequest(5, TimeUnit.SECONDS); + if (request == null) { + throw new IllegalStateException("Expected tracking plugin POST request"); + } + if (!"POST".equals(request.getMethod())) { + throw new IllegalStateException("Expected POST, got " + request.getMethod()); + } + if (!"/events".equals(request.getPath())) { + throw new IllegalStateException("Expected /events, got " + request.getPath()); + } + String userAgent = request.getHeader("User-Agent"); + if (userAgent == null || userAgent.endsWith("/unknown")) { + throw new IllegalStateException("Unexpected User-Agent: " + userAgent); + } + return JsonParser.parseString(request.getBody().readUtf8()).getAsJsonObject(); + } + + private static void requireHeaderShape(JsonObject body) { + if (!"sdk-smoke".equals(body.get("client_key").getAsString())) { + throw new IllegalStateException("Unexpected client_key: " + body.get("client_key")); + } + JsonArray events = body.getAsJsonArray("events"); + if (events == null || events.size() == 0) { + throw new IllegalStateException("Expected at least one event"); + } + } + + private static void requireAttribute(JsonObject body, int eventIndex, String key, String expected) { + JsonObject attributes = body.getAsJsonArray("events") + .get(eventIndex) + .getAsJsonObject() + .getAsJsonObject("attributes"); + String actual = attributes.get(key).getAsString(); + if (!expected.equals(actual)) { + throw new IllegalStateException("Expected attributes." + key + "=" + expected + ", got " + actual); + } + } + + private static void setGlobalContext(GrowthBookClient client) throws Exception { + Field globalContext = GrowthBookClient.class.getDeclaredField("globalContext"); + globalContext.setAccessible(true); + globalContext.set(client, GlobalContext.builder() + .features(TransformationUtil.transformFeatures(FEATURES_JSON)) + .enabled(true) + .build()); + } + + private static void printScenario(String scenario, JsonObject body) { + System.out.println("=== tracking plugin smoke: " + scenario + " ==="); + System.out.println(body); + } +}