Skip to content

feat: server-side tracking via EventLogger and deferred exposures - #260

Open
vazarkevych wants to merge 5 commits into
mainfrom
feat/server-side-tracking
Open

vazarkevych wants to merge 5 commits into
mainfrom
feat/server-side-tracking

Conversation

@vazarkevych

Copy link
Copy Markdown
Collaborator

feat: server-side tracking via EventLogger and deferred exposures

A structured EventLogger sink + deferred experiment-exposure tracking, across all three SDK facades

JS-SDK parity (Experiment Viewed / Feature Evaluated) · additive · guarded dispatch

build
language level
new tests

Branch: feat/server-side-tracking → main
Commit: f3b9ccc


Summary

Adds structured, server-side-friendly tracking:

  1. EventLogger SPI — a single event sink that receives built-in Experiment Viewed and
    Feature Evaluated events (names matching the GrowthBook JS SDK) plus application-defined
    logEvent(...) events. Configured via Options.eventLogger / GBContext.eventLogger, with an
    optional eventLoggerExecutor to run the sink off the evaluation thread.
  2. Deferred exposures — buffer experiment exposures during a request and flush them once at the
    end, instead of firing inline per evaluation.

Available on all three facades: GrowthBook (single-context), GrowthBookClient, and the per-request
UserScopedGrowthBook. The event logger fires in addition to (not instead of) the existing
TrackingCallbackWithUser / FeatureUsageCallbackWithUser. Everything is additive and backward
compatible
.


Why

  • Server-side analytics. Backends want one structured event stream (Experiment Viewed,
    Feature Evaluated, custom events) they can forward to a warehouse/queue, consistent with the JS SDK.
  • Request batching. On the server, firing an exposure callback inline per evaluation is wasteful;
    deferring lets a request collect exposures and flush them once (or hand them to another process).
  • Isolation. A slow or throwing sink must never affect evaluation — dispatch is guarded and can be
    pushed to an executor.

Key concepts

Piece Role
EventLogger functional sink: logEvent(name, properties, userContext)
GrowthBookEvent built-in event names: Experiment Viewed, Feature Evaluated
EventLoggerDispatch single, guarded dispatch point for the tracking callback + event logger (optionally via eventLoggerExecutor); stateless so evaluators stay stateless
DeferredTrackingBuffer / DeferredTrackingCall buffer exposures during a request; flush(options) fires + clears
EvaluationContext.deferredTracking when set, exposures are buffered instead of fired

How it flows

flowchart LR
    EV[FeatureEvaluator / ExperimentEvaluator] -->|exposure / usage| D{deferredTracking set?}
    D -->|no| DISP[EventLoggerDispatch]
    D -->|yes| BUF[DeferredTrackingBuffer.add]
    DISP --> CB[TrackingCallback / FeatureUsageCallback]
    DISP --> EL[EventLogger optionally via executor]
    BUF -->|fireDeferredTrackingCalls| FL["buffer.flush(options)"]
    FL --> DISP
Loading

What was done

New (multiusermode/usage, multiusermode)

EventLogger, GrowthBookEvent, EventLoggerDispatch, DeferredTrackingBuffer,
DeferredTrackingCall, and the per-request UserScopedGrowthBook (forUser(...) → context-free
evaluation that buffers exposures + logEvent + fireDeferredTrackingCalls).

Wiring

  • Options.eventLogger / eventLoggerExecutor; GBContext.eventLogger / deferTrackingCalls /
    eventLoggerExecutor.
  • GrowthBookClient: forUser(...), logEvent(...), and package-private buffer overloads of
    evalFeature/isOn/isOff/getFeatureValue/run + flushDeferredTracking.
  • GrowthBook (single-context): logEvent, setEventLogger, and
    get/setDeferredTrackingCalls() / fireDeferredTrackingCalls() gated by GBContext.deferTrackingCalls.
  • EvaluationContext carries an optional DeferredTrackingBuffer.

Evaluators

FeatureEvaluator and ExperimentEvaluator route exposures/usage through EventLoggerDispatch:
experiment exposures fire the tracking callback + the Experiment Viewed event (or buffer when
deferring); feature evaluations fire the usage callback + the Feature Evaluated event. Plugin
firing is preserved.


Files

File Change
multiusermode/usage/EventLogger.java new — sink SPI
multiusermode/usage/GrowthBookEvent.java new — built-in event names
multiusermode/usage/EventLoggerDispatch.java new — guarded dispatch (callback + logger)
multiusermode/DeferredTrackingBuffer.java / DeferredTrackingCall.java new — deferral buffer
multiusermode/UserScopedGrowthBook.java new — per-request handle with deferral + logEvent
multiusermode/GrowthBookClient.java forUser / logEvent / buffer overloads / flushDeferredTracking
GrowthBook.java logEvent / setEventLogger / deferred-tracking API
model/GBContext.java eventLogger / deferTrackingCalls / eventLoggerExecutor
multiusermode/configurations/Options.java eventLogger / eventLoggerExecutor (arity preserved)
multiusermode/configurations/EvaluationContext.java optional deferredTracking + buffer ctor
evaluators/FeatureEvaluator.java / ExperimentEvaluator.java route through EventLoggerDispatch + deferral
constants/SDKConstants.java EMPTY_STRING
GrowthBookDeferredTrackingTest, evaluators/EventLoggerEvaluationTest, multiusermode/DeferredTrackingBufferTest, multiusermode/UserScopedGrowthBookTest new — 27 cases

Usage

Java — event logger + deferred flush (multi-user)
Options options = Options.builder()
    .apiHost("https://cdn.growthbook.io")
    .clientKey("sdk-abc123")
    .eventLogger((name, properties, user) -> analytics.enqueue(name, properties))
    .build();
GrowthBookClient gb = new GrowthBookClient(options);
gb.initialize();

// Per-request scope: exposures are buffered, then flushed once at the end.
UserScopedGrowthBook user = gb.forUser(UserContext.builder().attributesJson("{\"id\":\"1\"}").build());
user.isOn("new-checkout");
user.run(myExperiment);
user.logEvent("Checkout Started", Map.of("cartId", cartId));
user.fireDeferredTrackingCalls();
Java — single-context deferral
GBContext ctx = GBContext.builder()
    .featuresJson(featuresJson)
    .attributesJson("{\"id\":\"1\"}")
    .eventLogger((name, props, user) -> analytics.enqueue(name, props))
    .deferTrackingCalls(true)
    .build();
GrowthBook gb = new GrowthBook(ctx);

gb.isOn("new-checkout");
gb.fireDeferredTrackingCalls(); // flush buffered exposures once

Public API surface

Consumer touches Kind Stability
Options.builder().eventLogger(...) / .eventLoggerExecutor(...) new options additive
GBContext.builder().eventLogger(...) / .deferTrackingCalls(...) / .eventLoggerExecutor(...) new options additive
GrowthBookClient.forUser(...) / logEvent(...) new methods additive
UserScopedGrowthBook new type additive
GrowthBook.logEvent/setEventLogger/get-set-fireDeferredTrackingCalls new methods additive
EventLogger, GrowthBookEvent new types additive
existing Options / EvaluationContext constructors preserved (delegating overloads) no break

Tests

Suite Covers
EventLoggerEvaluationTest (10) Experiment Viewed / Feature Evaluated payloads and property shapes; fires alongside callbacks; guarded against throwing sinks; deferral path
DeferredTrackingBufferTest (8) buffer add/flush/clear; de-dup; get/set round-trip
GrowthBookDeferredTrackingTest (6) single-context deferral gated by deferTrackingCalls; fireDeferredTrackingCalls; logEvent
UserScopedGrowthBookTest (3) forUser buffering + flush; context-free evaluation

./gradlew build passes on JDK 17; the full :lib suite is green (the dispatch rewrite keeps existing tracking tests passing).


Compatibility & scope notes

  • Additive / backward compatible. New parameters use delegating overloads; OptionsTest's
    constructor-arity guard still passes, and EvaluationContext keeps its existing constructor.
  • Ported from the internal feat/server-side-tracking branch, adapted. The internal branch
    reordered EvaluationContext's constructor params; to avoid breaking every public call site, the
    public port keeps the existing field order and merely adds a nullable deferredTracking field +
    a buffer-carrying constructor — so the internal GlobalContextManager / RemoteEvalCoordinator /
    perf-harness / ForcedOverridesEvaluatorTest reorder churn is intentionally omitted. The feature is
    integrated into the public dispatchExperimentViewed / dispatchFeatureUsage helpers (which already
    fire plugins), rather than the internal branch's older inline tracking.
  • Independent of feat/user-scoped-instances. That branch ships a different UserScopedGrowthBook
    (createScopedInstance + per-user state); this one ships its own (forUser + deferral). If both land,
    the overlap is resolved at merge time.
  • Build: requires JDK 17 (the default JDK 26 crashes the Lombok version used here), matching CI.

Add a structured EventLogger sink and deferred experiment-exposure tracking,
across all three facades (GrowthBook, GrowthBookClient, UserScopedGrowthBook),
mirroring the JS SDK's "Experiment Viewed" / "Feature Evaluated" events.

- EventLogger SPI + GrowthBookEvent names + EventLoggerDispatch (single, guarded
  dispatch point for the tracking callback and the event logger; optional
  eventLoggerExecutor to run the sink off the evaluation thread). Configured via
  Options.eventLogger / GBContext.eventLogger.
- Evaluators route exposures/usage through EventLoggerDispatch: experiment
  exposures also fire the EventLogger; feature evaluations emit "Feature Evaluated".
- Deferred tracking: DeferredTrackingBuffer/DeferredTrackingCall buffer exposures
  during a request and flush once at the end. EvaluationContext carries an optional
  buffer; GrowthBookClient.forUser(...) returns a UserScopedGrowthBook that buffers
  and exposes fireDeferredTrackingCalls(). Single-context GrowthBook gains
  get/setDeferredTrackingCalls()/fireDeferredTrackingCalls() gated by
  GBContext.deferTrackingCalls. logEvent(...) emits custom events on all facades.

New parameters are added via delegating overloads (Options keeps its 30-arg arity;
EvaluationContext keeps its existing constructor and adds a buffer-carrying one).
Adapted from the internal feat/server-side-tracking branch: ported onto the public
EvaluationContext field order (no constructor reorder) and the existing
dispatchExperimentViewed/dispatchFeatureUsage helpers.
@greptile-apps

greptile-apps Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5 Tier: plus

[Medium impact] The PR appears safe to merge, with a non-blocking correction needed in the sink's object-value docs.

Reviews (2) · Last reviewed commit: "feat: server-side tracking via EventLogg..." · Reviewed by Greptile

Comment thread lib/src/main/java/growthbook/sdk/java/model/GBContext.java
Comment thread lib/src/main/java/growthbook/sdk/java/multiusermode/DeferredTrackingBuffer.java Outdated
Comment thread lib/src/main/java/growthbook/sdk/java/multiusermode/DeferredTrackingBuffer.java Outdated
Comment thread lib/src/main/java/growthbook/sdk/java/multiusermode/DeferredTrackingBuffer.java Outdated
Comment thread lib/src/main/java/growthbook/sdk/java/multiusermode/GrowthBookClient.java Outdated
Comment thread lib/src/main/java/growthbook/sdk/java/multiusermode/usage/EventLogger.java Outdated
Comment thread lib/src/main/java/growthbook/sdk/java/evaluators/ExperimentEvaluator.java Outdated
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

@greptile review

…tibility

Adding the event logger widened the positional GBContext constructor from 23 to
26 arguments in place, so callers compiled against the old signature break at
compile time and already-compiled callers fail with NoSuchMethodError. Add a
delegating 23-argument constructor (the pre-event-logger signature) that passes
null for the event logger, deferred tracking, and event logger executor, and a
test asserting the 16/23/26 positional arities stay available.
Comment thread lib/src/main/java/growthbook/sdk/java/multiusermode/usage/EventLogger.java Outdated
Volodymyr Nazarkevych added 3 commits October 9, 2026 18:27
Four related server-side-tracking fixes:

- Deferred exposures reached only the tracking callback and event logger, never
  the registered plugins that the immediate path notifies. Thread the owning
  PluginRegistry through the flush path so plugins receive buffered exposures.
- flush() iterated the live buffer while invoking application callbacks; a
  callback that evaluated another experiment-backed feature through the same
  scope could add an entry mid-iteration, throwing ConcurrentModificationException
  or clearing undelivered entries. Drain a snapshot and clear before dispatching;
  entries added during flush stay for a later flush.
- Buffered calls retained the caller's mutable Experiment/ExperimentResult, so
  reusing and mutating them (e.g. setKey) between run and flush rewrote queued
  events. Snapshot the exposure via toBuilder() when buffering.
- logEvent built a full evaluation context just to resolve the user, which in
  remote mode fetched feature data on a cache miss and queried sticky-bucket
  services even with no event logger configured. Short-circuit when no logger is
  set and merge user attributes without touching evaluation data.
Document that the EventLogger sink runs on the evaluation thread by default but on
eventLoggerExecutor when configured, and that implementations must be concurrency-safe
and not rely on request-local thread state. Also remove the deferred-branch comment in
ExperimentEvaluator that restated the code; the deferral contract lives on
DeferredTrackingBuffer.
Add scope-level cases for feature evaluation: value and typed-key getters, a missing
feature returning off/default, a null key treated as missing, and an experiment-backed
feature buffering its exposure until fireDeferredTrackingCalls.

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