Skip to content

fix: send tracking events to /track with the JS/Go wire contract - #262

Open
vazarkevych wants to merge 1 commit into
mainfrom
fix/tracking-plugin-track-endpoint
Open

vazarkevych wants to merge 1 commit into
mainfrom
fix/tracking-plugin-track-endpoint

Conversation

@vazarkevych

Copy link
Copy Markdown
Collaborator

fix: tracking plugin posts to /track with the JS/Go wire contract

GrowthBookTrackingPlugin now actually records events — correct endpoint, correct payload, with user attributes

JS / Go / Python SDK parity · fixes a silent no-op · backward compatible

build
language level
tests

Branch: fix/tracking-plugin-track-endpoint → main
Commit: efad482


Summary

GrowthBookTrackingPlugin sent events to the wrong endpoint with the wrong body, so no events were ever recorded by the GrowthBook ingestor. This change makes the plugin speak the same wire contract as the JS and Go SDKs:

  • Endpoint: POST {ingestorHost}/track?client_key={clientKey} (was POST {ingestorHost}/events)
  • Body: a top-level JSON array of events (was {"client_key": ..., "events": [...]})
  • Event shape: event_name + properties + attributes + sdk_language + sdk_version
  • variationId is the variation key, not its numeric index
  • attributes carries the user's attributes at evaluation time, so events can be tied to users and sliced by dimension

The fix is additive and backward compatible for custom GrowthBookPlugin implementations.


Why

The GrowthBook ingestor only serves POST /track. The Java plugin posted to /track's predecessor shape (/events with a {client_key, events} envelope), which the ingestor does not accept — so telemetry silently went nowhere.

The other server SDKs hit the same problem and fixed it:

  • Go SDK — fixed in v0.5.0 (CHANGELOG)
  • Python SDK — fixed in 2.2.0

This brings the Java SDK in line with both, and with the JS SDK's Experiment Viewed / Feature Evaluated event names and payload shape.

A second, related gap: the plugin SPI never received the evaluated user's attributes, so even a correctly-delivered event could not be attributed to a user. This change threads attributes through to the plugin.


Before → after (on the wire)

Before — POST {ingestorHost}/events

{
  "client_key": "sdk-abc123",
  "events": [
    { "event_type": "experiment_viewed", "experiment_id": "my-experiment", "variation_id": 1, "timestamp": 1736500000000, "...": "flat Go-internal fields" }
  ]
}

After — POST {ingestorHost}/track?client_key=sdk-abc123

[
  {
    "event_name": "Experiment Viewed",
    "properties": {
      "experimentId": "my-experiment",
      "variationId": "1",
      "hashAttribute": "id",
      "hashValue": "user-123"
    },
    "attributes": { "id": "user-123", "country": "US" },
    "sdk_language": "java",
    "sdk_version": "0.11.1"
  }
]

Feature-usage events use "event_name": "Feature Evaluated" with feature, value, source, ruleId, and variationId in properties:

{
  "event_name": "Feature Evaluated",
  "properties": { "feature": "flag", "value": true, "source": "experiment", "ruleId": "rule-7", "variationId": "2" },
  "attributes": { "id": "user-123" },
  "sdk_language": "java",
  "sdk_version": "0.11.1"
}

Verified live via the smoke harness (./gradlew :lib:runTrackingPluginSmoke), which now prints exactly this shape.


What was done

1. Carry user attributes through the plugin SPI (additive)

GrowthBookPlugin gains attribute-carrying overloads that default-delegate to the existing ones, so custom plugins are untouched:

default <V> void onExperimentViewed(Experiment<V> e, ExperimentResult<V> r, @Nullable JsonObject userAttributes) {
    onExperimentViewed(e, r);
}
default <V> void onFeatureEvaluated(String key, FeatureResult<V> r, @Nullable JsonObject userAttributes) {
    onFeatureEvaluated(key, r);
}

PluginRegistry gains matching 3-arg fire* methods (the old 2-arg ones delegate with null), and both dispatch sites — ExperimentEvaluator and FeatureEvaluator — pass context.getUser().getAttributes() (null-guarded).

2. New event shape (TrackingEvent)

Rebuilt around event_name / properties / attributes / sdk_language / sdk_version. Null properties are omitted; attributes is omitted when none are supplied. Attributes are deep-copied at construction (on the evaluation thread, before buffering) so later mutation of the user context cannot alter a buffered event. variationId is sourced from the variation key (ExperimentResult.getKey()).

3. Correct endpoint and body (GrowthBookTrackingPlugin)

Builds the URL with OkHttp HttpUrl — {ingestorHost}/track + client_key query parameter (properly encoded; malformed host is logged and skipped, never thrown) — and posts a top-level JsonArray of events. Batching, backpressure, and close/flush semantics are unchanged.

4. Docs

GrowthBookTrackingPlugin and TrackingPluginConfig Javadoc updated from /events to the /track?client_key= contract.


Backward compatibility

  • Custom plugins: unaffected. PluginRegistry calls the 3-arg overload; the interface default routes to a plugin's existing 2-arg override, so plugins that only implement onExperimentViewed(e, r) keep receiving every event.
  • Public API: no public/protected signatures removed or changed; only additive default methods and overloads. TrackingEvent is package-private, so its field reshaping is internal.
  • Wire format: intentionally changed — that is the fix. The previous shape was never accepted by the ingestor, so there is no working consumer to break.

Tests

GrowthBookTrackingPluginTest asserts the contract at the wire level:

  • POST /track, client_key=sdk-test query, top-level JSON array body
  • event_name ("Experiment Viewed" / "Feature Evaluated"), nested properties.*, variationId as the key string
  • experiment and feature-backed-experiment property maps; attributes presence and content
  • absent paths: attributes omitted on the 2-arg (no-attributes) path; variationId omitted for a non-experiment feature evaluation
  • end-to-end forwardsUserAttributesFromEvaluation through GrowthBook (evaluator → registry → plugin → POST)
  • existing lifecycle coverage retained: timer flush, eager batch flush, final flush on close(), caller-supplied executor wait, closeTimeout bound, shared-instance misuse, disabled/no-client-key no-ops, HTTP-failure tolerance

RecordingHttpServer now captures the request query string. Full ./gradlew :lib:test is green on JDK 17.


Notes / out of scope

  • The experiment properties carry exactly experimentId, variationId, hashAttribute, hashValue — matching the agreed contract (no value/name on Experiment Viewed).
  • When an experiment defines no variation meta keys, variationId falls back to the stringified index (or is omitted if unknown) — same as the JS SDK.
  • The plugin HTTP tests use the existing RecordingHttpServer (JDK com.sun HTTP server); converting the tracking tests to WireMock is pre-existing and left out of this focused fix.

The tracking plugin POSTed {ingestorHost}/events with {client_key, events:[...]} and
flat event fields, but the GrowthBook ingestor only serves POST /track, so no events
were recorded (same bug the Go and Python SDKs fixed).

Post to {ingestorHost}/track?client_key={clientKey} with a top-level JSON array of
events shaped like the JS and Go SDKs: event_name ("Experiment Viewed" /
"Feature Evaluated"), a properties map, the user's attributes at evaluation time, and
sdk_language/sdk_version. variationId now carries the variation key, not its index.

To supply attributes, GrowthBookPlugin gains attribute-carrying onExperimentViewed /
onFeatureEvaluated overloads (default-delegating to the existing ones, so custom plugins
are unaffected); PluginRegistry and both evaluators thread the evaluated user's
attributes through to them.
@greptile-apps

greptile-apps Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Critical impact] Changes the wire contract for tracking events sent to the ingest endpoint.

The PR appears safe to merge, with two non-blocking test improvements.

Reviews (1) · Last reviewed commit: "fix: send tracking events to /track with..." · Reviewed by Greptile


JsonObject properties = event.getAsJsonObject("properties");
assertEquals("exp1", properties.get("experimentId").getAsString());
assertEquals("3", properties.get("variationId").getAsString());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Distinct variation keys go untested

Both new event tests use variation keys that equal their numeric indexes. They would still pass if variationId were changed back to the stringified index. Add a result with index 1 and key "treatment", then assert that both event types send "treatment".

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/src/test/java/growthbook/sdk/java/plugin/tracking/GrowthBookTrackingPluginTest.java
Line: 126

Comment:
**Distinct variation keys go untested**

Both new event tests use variation keys that equal their numeric indexes. They would still pass if `variationId` were changed back to the stringified index. Add a result with index `1` and key `"treatment"`, then assert that both event types send `"treatment"`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

GrowthBookTrackingPlugin plugin = GrowthBookTrackingPlugin.of(configBuilder().batchSize(1).build());
plugin.init();
plugin.onExperimentViewed(experiment("exp1"), experimentResult(3));
plugin.onExperimentViewed(experiment("exp1"), experimentResult(3), attributes());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Attribute copies go untested

The new attribute tests flush immediately and never change the original attributes. They do not protect the promised copy-at-evaluation behavior. Add a test that buffers an event, changes a nested attribute on the original object, then flushes and checks that the event kept the old value.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/src/test/java/growthbook/sdk/java/plugin/tracking/GrowthBookTrackingPluginTest.java
Line: 114

Comment:
**Attribute copies go untested**

The new attribute tests flush immediately and never change the original attributes. They do not protect the promised copy-at-evaluation behavior. Add a test that buffers an event, changes a nested attribute on the original object, then flushes and checks that the event kept the old value.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant