Repository navigation
feat: server-side tracking via EventLogger and deferred exposures - #260
Open
vazarkevych wants to merge 5 commits into
Open
vazarkevych wants to merge 5 commits into
vazarkevych wants to merge 5 commits into
Conversation
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.
|
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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat: server-side tracking via EventLogger and deferred exposures
A structured
EventLoggersink + deferred experiment-exposure tracking, across all three SDK facadesJS-SDK parity (
Experiment Viewed/Feature Evaluated) · additive · guarded dispatchBranch:
feat/server-side-tracking→mainCommit:
f3b9cccSummary
Adds structured, server-side-friendly tracking:
EventLoggerSPI — a single event sink that receives built-inExperiment ViewedandFeature Evaluatedevents (names matching the GrowthBook JS SDK) plus application-definedlogEvent(...)events. Configured viaOptions.eventLogger/GBContext.eventLogger, with anoptional
eventLoggerExecutorto run the sink off the evaluation thread.end, instead of firing inline per evaluation.
Available on all three facades:
GrowthBook(single-context),GrowthBookClient, and the per-requestUserScopedGrowthBook. The event logger fires in addition to (not instead of) the existingTrackingCallbackWithUser/FeatureUsageCallbackWithUser. Everything is additive and backwardcompatible.
Why
Experiment Viewed,Feature Evaluated, custom events) they can forward to a warehouse/queue, consistent with the JS SDK.deferring lets a request collect exposures and flush them once (or hand them to another process).
pushed to an executor.
Key concepts
EventLoggerlogEvent(name, properties, userContext)GrowthBookEventExperiment Viewed,Feature EvaluatedEventLoggerDispatcheventLoggerExecutor); stateless so evaluators stay statelessDeferredTrackingBuffer/DeferredTrackingCallflush(options)fires + clearsEvaluationContext.deferredTrackingHow 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 --> DISPWhat was done
New (
multiusermode/usage,multiusermode)EventLogger,GrowthBookEvent,EventLoggerDispatch,DeferredTrackingBuffer,DeferredTrackingCall, and the per-requestUserScopedGrowthBook(forUser(...)→ context-freeevaluation that buffers exposures +
logEvent+fireDeferredTrackingCalls).Wiring
Options.eventLogger/eventLoggerExecutor;GBContext.eventLogger/deferTrackingCalls/eventLoggerExecutor.GrowthBookClient:forUser(...),logEvent(...), and package-private buffer overloads ofevalFeature/isOn/isOff/getFeatureValue/run+flushDeferredTracking.GrowthBook(single-context):logEvent,setEventLogger, andget/setDeferredTrackingCalls()/fireDeferredTrackingCalls()gated byGBContext.deferTrackingCalls.EvaluationContextcarries an optionalDeferredTrackingBuffer.Evaluators
FeatureEvaluatorandExperimentEvaluatorroute exposures/usage throughEventLoggerDispatch:experiment exposures fire the tracking callback + the
Experiment Viewedevent (or buffer whendeferring); feature evaluations fire the usage callback + the
Feature Evaluatedevent. Pluginfiring is preserved.
Files
multiusermode/usage/EventLogger.javamultiusermode/usage/GrowthBookEvent.javamultiusermode/usage/EventLoggerDispatch.javamultiusermode/DeferredTrackingBuffer.java/DeferredTrackingCall.javamultiusermode/UserScopedGrowthBook.javalogEventmultiusermode/GrowthBookClient.javaforUser/logEvent/ buffer overloads /flushDeferredTrackingGrowthBook.javalogEvent/setEventLogger/ deferred-tracking APImodel/GBContext.javaeventLogger/deferTrackingCalls/eventLoggerExecutormultiusermode/configurations/Options.javaeventLogger/eventLoggerExecutor(arity preserved)multiusermode/configurations/EvaluationContext.javadeferredTracking+ buffer ctorevaluators/FeatureEvaluator.java/ExperimentEvaluator.javaEventLoggerDispatch+ deferralconstants/SDKConstants.javaEMPTY_STRINGGrowthBookDeferredTrackingTest,evaluators/EventLoggerEvaluationTest,multiusermode/DeferredTrackingBufferTest,multiusermode/UserScopedGrowthBookTestUsage
Java — event logger + deferred flush (multi-user)
Java — single-context deferral
Public API surface
Options.builder().eventLogger(...)/.eventLoggerExecutor(...)GBContext.builder().eventLogger(...)/.deferTrackingCalls(...)/.eventLoggerExecutor(...)GrowthBookClient.forUser(...)/logEvent(...)UserScopedGrowthBookGrowthBook.logEvent/setEventLogger/get-set-fireDeferredTrackingCallsEventLogger,GrowthBookEventOptions/EvaluationContextconstructorsTests
EventLoggerEvaluationTest(10)Experiment Viewed/Feature Evaluatedpayloads and property shapes; fires alongside callbacks; guarded against throwing sinks; deferral pathDeferredTrackingBufferTest(8)GrowthBookDeferredTrackingTest(6)deferTrackingCalls;fireDeferredTrackingCalls;logEventUserScopedGrowthBookTest(3)forUserbuffering + flush; context-free evaluation./gradlew buildpasses on JDK 17; the full:libsuite is green (the dispatch rewrite keeps existing tracking tests passing).Compatibility & scope notes
OptionsTest'sconstructor-arity guard still passes, and
EvaluationContextkeeps its existing constructor.feat/server-side-trackingbranch, adapted. The internal branchreordered
EvaluationContext's constructor params; to avoid breaking every public call site, thepublic port keeps the existing field order and merely adds a nullable
deferredTrackingfield +a buffer-carrying constructor — so the internal
GlobalContextManager/RemoteEvalCoordinator/perf-harness /
ForcedOverridesEvaluatorTestreorder churn is intentionally omitted. The feature isintegrated into the public
dispatchExperimentViewed/dispatchFeatureUsagehelpers (which alreadyfire plugins), rather than the internal branch's older inline tracking.
feat/user-scoped-instances. That branch ships a differentUserScopedGrowthBook(
createScopedInstance+ per-user state); this one ships its own (forUser+ deferral). If both land,the overlap is resolved at merge time.