Skip to content

Add plugins support for both legacy and new clients - #206

Merged
madhuchavva merged 15 commits into
mainfrom
feat/tracking-plugin
Jul 30, 2026
Merged

Add plugins support for both legacy and new clients#206
madhuchavva merged 15 commits into
mainfrom
feat/tracking-plugin

Conversation

@madhuchavva

Copy link
Copy Markdown
Contributor

Summary

Adds support for GrowthBook plugins and ports the built-in tracking plugin pattern for the legacy client.

What changed

  • Wired plugin lifecycle into both single-user (GrowthBook / GBContext) and multi-user (GrowthBookClient / Options) modes
  • Routed experiment and feature evaluation events through the shared evaluator path so plugins receive the same events in both modes
  • Preserved existing callback behavior by chaining plugin dispatch after TrackingCallback / FeatureUsageCallback
  • Added a built-in GrowthBookTrackingPlugin that batches experiment_viewed and feature_evaluated events and POSTs them to the ingest endpoint
  • Added tests for plugin lifecycle, evaluator integration, batching, timer-based flushing, close-time flushing, and failure/no-op behavior

Wire contract

  • Endpoint: POST {ingestorHost}/events
  • Default host: https://us1.gb-ingest.com
  • Body: { "client_key": "...", "events": [...] }
  • Headers: Content-Type: application/json, User-Agent: growthbook-java-sdk/{version}
  • Event types: experiment_viewed, feature_evaluated
  • Batch defaults: batchSize=100, batchTimeout=10s
  • close() performs a synchronous final flush
  • Initialization failures degrade to no-op behavior

@vazarkevych vazarkevych left a comment

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.

Overall

Nice work — the plugin architecture itself is clean: no-op-default interface, a
best-effort PluginRegistry that isolates plugin failures, sensible no-op degradation,
and genuinely good test coverage (real MockWebServer, batch/timer/close/failure paths,
both single- and multi-user wiring). Most of my line comments are quality/consistency
nits. There are, however, three things I'd treat as blockers and a recurring theme worth
addressing in one pass.

Must-fix

  1. Data privacy (default attribute export). snapshotAttributes() ships the full
    user attribute map — plus hash_value and the feature/experiment value — to the
    ingest endpoint (US default host) on every event, opt-out rather than opt-in. For
    GDPR users this makes the plugin hard to enable safely. Please make attribute export
    opt-in (allowlist, empty by default) with a configurable region/host, and document
    that enabling the plugin transfers user data to a third party.

  2. Remote-eval path is behind main and re-fires exposures. The inline
    trackData.forEach(...) loop predates #216, which replaced it with
    experimentEvaluator.fireRemoteEvaluationTracks(...) (de-dupes per assignment,
    null-guards payloads). As written, plugins get duplicate experiment_viewed on cached
    remote-eval responses, unlike the local isExperimentTracked path. Rebase and route
    plugin exposure through that shared, de-duplicated path.

  3. Memory/latency under load. The owned flush executor uses an unbounded queue, and
    feature_evaluated fires on every evaluation, so a slow/failing endpoint lets batches
    pile up unbounded. On top of that, snapshotAttributes() does a full deepCopy() on
    the evaluation thread and every buffered event retains its own copy. Needs a bounded
    queue + drop policy, and the attribute capture off the hot path (an allowlist also
    shrinks this a lot).

Recurring theme — keep the evaluators small, catch narrowly

  • The plugin hooks are inlined into the already-huge evaluateExperiment /
    evaluateFeature, even though this PR already introduced dispatchFeatureUsage(...)
    in the same file. Add a dispatchExperimentViewed(...) helper and route all three
    sites through helpers so the methods shrink rather than grow — and trim the redundant
    "what" comments while you're touching those blocks.
  • catch (Throwable) is used broadly (plugin flush, submitFlush, TrackingEvent.toJson,
    PluginRegistry). Several are redundant (flushBatch already swallows its
    IOException), and Throwable hides Errors/bugs. Narrow to Exception, handle each
    failure once, and log rather than silently dropping.

Lifecycle / API

  • init() does nothing; all setup (threads, http client) is in the constructor, so a
    disabled or never-registered plugin still spins up executors. Move setup into init()
    and keep the constructor side-effect-free.
  • close() bundles ~5 concerns — extract finalFlush()/shutdownScheduler()/…. On the
    GrowthBook side, the new close() should probably implement AutoCloseable, and the
    if (pluginRegistry != null) guards on a final field are dead.
  • TrackingPluginConfig exposes both raw nullable getters and resolved*() — easy to
    grab the wrong one; hide the raw ones. batchSize has no upper clamp.

Nits

ValueType vs idiomatic <V>; hand-rolled Builder vs the Lombok @Builder used
elsewhere; eventType String vs enum; stripTrailingSlash/isEmpty duplicating
StringUtils/RemoteEvalRequestBuilder.normalizeUrl; // --- internal --- divider;
single-letter param names; per-event sdk_language/sdk_version that could live on the
batch envelope.

We completely understand if you're busy at the moment. If it's helpful, we'd be happy to work on these improvements ourselves and open PRs for you to review when you have time.

Comment thread lib/src/main/java/growthbook/sdk/java/evaluators/ExperimentEvaluator.java Outdated
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Kept ValueType here deliberately: the whole evaluator layer (ExperimentEvaluator/FeatureEvaluator signatures) uses <ValueType>, so switching just this helper to V would make the file inconsistent. Happy to do a repo-wide rename to the single-letter convention as a separate cleanup PR if you'd prefer.

Comment thread lib/src/main/java/growthbook/sdk/java/evaluators/FeatureEvaluator.java Outdated
Comment thread lib/src/main/java/growthbook/sdk/java/evaluators/FeatureEvaluator.java Outdated
Comment thread lib/src/main/java/growthbook/sdk/java/plugin/PluginRegistry.java
Comment thread lib/src/main/java/growthbook/sdk/java/plugin/PluginRegistry.java
Comment thread lib/src/main/java/growthbook/sdk/java/plugin/PluginRegistry.java
Comment thread lib/src/main/java/growthbook/sdk/java/plugin/PluginRegistry.java
# Conflicts:
#	lib/build.gradle
#	lib/src/main/java/growthbook/sdk/java/GrowthBook.java
#	lib/src/main/java/growthbook/sdk/java/evaluators/FeatureEvaluator.java
#	lib/src/main/java/growthbook/sdk/java/model/GBContext.java
#	lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java
#	lib/src/main/java/growthbook/sdk/java/multiusermode/configurations/Options.java
- TrackingEvent: match Go event fields (experiment_id, variation_value,
  feature_value, source, in_experiment, hash_used, on, off, feature_id,
  experiment_name), epoch-millis timestamp, event_type enum, Lombok
  @builder; drop the user-attribute export (GDPR) — Go sends none.
- Simplify GrowthBookPlugin: remove the EvaluationContext overloads whose
  only purpose was attribute capture; PluginRegistry.fire* drop the context.
- PluginRegistry: filter null plugins, catch Exception (not Throwable) so
  Errors propagate, drop plugins whose init() fails, consistent isEmpty guards.
- GrowthBookTrackingPlugin: create resources in init() (side-effect-free
  constructor, no thread leak when disabled/unregistered); bounded flush
  queue with drop-newest; split close() into helpers; handle flush failure
  at the source.
- TrackingPluginConfig: StringUtils.isBlank, hide raw getters behind
  resolved*(), clamp batchSize.
- ExperimentEvaluator: extract dispatchExperimentViewed helper used by both
  the local and remote-eval paths.
- Carry the PluginRegistry on EvaluationContext (per evaluation) instead of
  mutating the shared, caller-owned Options. GrowthBookClient now owns its
  registry, so reusing one Options across clients no longer lets one client's
  evaluations or shutdown() reach another client's plugins. Registry removed
  from Options.
- FeatureEvaluator: the global forced-override path now calls
  dispatchFeatureUsage, so callbacks and plugins receive feature_evaluated for
  forced values, matching the URL-override and normal paths. (Memoized and
  exception-fallback returns are intentionally left: memoized already fired on
  first evaluation; the catch block is an error fallback, not a real usage.)
MockWebServer (okhttp) intermittently failed on CI with
NoClassDefFoundError: okhttp3/internal/Util despite okhttp 4.11.0 resolving
cleanly. Swap the plugin HTTP tests to a small JDK com.sun.net.httpserver
based RecordingHttpServer and drop the mockwebserver test dependency, so the
tests no longer couple to okhttp internals on the test classpath.
…nt loss

A timer (or eager) flush drained the buffer and released the lock before
submitFlush() registered the batch as in-flight. A concurrent close() could
observe an empty buffer and zero in-flight batches, shut down the executor and
HTTP client, and return before the batch was submitted — the batch was then
rejected/failed and its events silently lost, violating the synchronous-flush
contract. Move beginFlush() inside the buffer lock in enqueue() and
scheduledFlush() so close() always observes and waits for the batch. Adds a
timer-vs-close regression test.
… seam

The previous test could pass even when the timer never fired (close()'s
finalFlush would send the buffered event), so it did not prove the
drain-then-close ordering. Add a narrowly-scoped test seam invoked on the timer
thread between reserving a batch in-flight and submitting it, and use two
latches to pin the interleaving: the timer drains + reserves, parks at the
boundary, close() runs concurrently, then the timer submits. The test asserts
close() stayed alive until the reserved batch was submitted and that the batch
was delivered — it fails on the pre-fix ordering (reserve after unlock) and
passes with the reservation under the lock.
Three non-blocking lifecycle items from review:
- closeTimeout now bounds the synchronous final POST (via an OkHttp call
  timeout on a client derived from the configured one), not just the async
  in-flight wait, so close()/destroy() can't block past the budget on a hung
  final request. Doc corrected.
- enqueue() re-checks closed under the buffer lock: a request racing a
  concurrent shutdown now drops its event cleanly instead of orphaning it in
  the buffer or scheduling on a stopped scheduler.
- Document "one tracking plugin instance per SDK client" and warn when init()
  is called more than once (the tell-tale of a shared instance), since a shared
  plugin's first close() disables tracking for all owners.

Adds regression tests (real plugin + JDK server): closeTimeout bounds a hung
final flush, and a shared-plugin-across-instances test documenting the
constraint. RecordingHttpServer gains a response-delay hook.
@madhuchavva
madhuchavva merged commit 70b6cdc into main Jul 30, 2026
6 checks passed
@madhuchavva
madhuchavva deleted the feat/tracking-plugin branch July 30, 2026 18:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants