Skip to content

feat: experimentation support - #226

Open
Zaimwa9 wants to merge 38 commits into
mainfrom
feat/experimentation-support
Open

Zaimwa9 wants to merge 38 commits into
mainfrom
feat/experimentation-support

Conversation

@Zaimwa9

@Zaimwa9 Zaimwa9 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Thanks for submitting a PR! Please check the boxes below:

  • I have read the Contributing Guide.
  • I have added information to docs/ if required so people know about the feature.
  • I have filled in the "Changes" section below.
  • I have filled in the "How did you test this code" section below.

Changes

Experimentation support, opt-in via FlagsmithConfig.Builder.withEnableEvents(true). Nothing changes for existing users.

  • Flag gains variant, reason and experiment { id, name, inExperiment } from remote identity evaluation. Local evaluation now sets reason from the engine.
  • New EventProcessor: buffers events, dedupes exposures per flush window, POSTs { "events": [...] } to {eventsUri}v1/events every 10 s or at 1000 events. Retries a failed batch once on a connection error or 5xx, never on 4xx, then drops it; never re-queues. Transient traits are not sent. maxBufferItems is also the batch size, as in the Node SDK. In-flight batches are capped at 2; while both are in flight, events keep buffering up to 1 000 (or maxBufferItems if larger) and go out as one batch when a slot frees, dropping the oldest beyond that. The 1 000 bound keeps a catch-up batch no larger than a default batch, well under the events API's 2 MB body limit (a 413 is not retried).
  • New FlagsmithClient methods: getExperimentFlag, trackEvent, trackExposureEvent, flushEvents. close() now flushes, bounded by the client's configured timeouts.
  • Local evaluation and offline mode never carry experiment metadata and never record exposures. Enabling events in offline mode is rejected at build time. Generated engine classes are untouched.
  • Retry gains an opt-in statusForcelistOnly: the existing isRetry retries a force-listed status regardless of the attempts budget, which would loop forever on a persistent 5xx. Default unchanged. RequestProcessor gains submit() returning a CompletableFuture.

Spec differences: close() derives its bound from the HTTP client's timeouts and the retry policy instead of a requestTimeoutMillis parameter. Retries cover the whole 5xx range.

Docs live at docs.flagsmith.com; the README defers to them and needs no change.

How did you test this code?

  • mvn clean install and mvn clean install -P test-okhttp4: 479 passed each, 0 checkstyle violations. Engine conformance suite untouched and green.
  • New suites: EventProcessorTest (buffering, dedupe matrix, headers/body, max-buffer flush, cross-thread flush completion, retry-then-drop for 5xx/4xx/connection failure, in-flight cap, per-event serialisation failures, close/start races, timer), FeatureStateModelTest, FlagsmithRetryTest additions, FlagsmithClientTest additions (full getExperimentFlag gate matrix, identity-API timeout, local evaluation, close()).

Parse experiment metadata from remote evaluation and add an opt-in event
processor so SDK users can resolve experiment flags and record exposures.

- Flag and FeatureStateModel now carry variant, reason and experiment
  (metadata.experiment), populated by remote evaluation only. Local
  evaluation sets reason from FlagResult; variant and experiment stay null
  because the environment document has no variant keys.
- New EventProcessor buffers events and POSTs {"events": [...]} to
  {eventsUri}v1/events, flushing on a 10s timer, at 1000 buffered events
  and on close(). Exposures are deduplicated per flush window; a failed
  batch is retried once on a connection error or 5xx, never on 4xx, then
  dropped. Nothing thrown inside it reaches caller code.
- New client methods: getExperimentFlag, trackEvent, trackExposureEvent
  and flushEvents. close() now also closes the event processor.
- Opt in with FlagsmithConfig.Builder.withEnableEvents(true). Configuring
  the buffer, interval or events URI without enabling events is rejected
  at build time, as is enabling events in offline mode.
- Retry gains an opt-in statusForcelistOnly flag so a force-listed status
  respects the attempts budget instead of retrying forever. The default
  stays false, preserving existing behaviour.
- RequestProcessor gains submit(), returning a CompletableFuture so the
  event processor can compose on batch completion.

Nothing changes for users who do not opt in.
Three defects found in adversarial review of the event processor.

flush() registered a batch as in-flight only after serialising it and
building the request, both outside the buffer lock. A concurrent flush()
could observe an empty buffer and an in-flight set that did not yet
contain the batch, and return an already-completed future. The batch is
now created and added to inFlight inside the same synchronized block
that empties the buffer.

send() added the tracking future to inFlight before submitting. If
submit threw - RejectedExecutionException once the request processor is
closed, or anything out of newPostRequest - the future was left pending
forever, wedging every later flush() and burning the full close()
timeout. send() now settles it in a finally block on every path, and
buffering is a no-op once the processor is closed.

Traits were put on the wire verbatim, so a TraitConfig value serialised
as {"value":..,"isTransient":..} instead of the flat map the events API
expects, and a trait the caller marked transient was shipped to the
event store. Values are now unwrapped through TraitConfig, transient
traits are dropped, and the map is copied at buffer time so a caller
mutating it cannot change a buffered event.

Also covers the retry paths that had no tests: connection failures, and
Retry.isRetry under statusForcelistOnly, whose attempts-budget branch is
what stops a permanently failing endpoint from retrying forever. The
timer flush test now waits on a latch instead of sleeping.
eventsUri() marked the events config as touched, so setting a custom
events host threw at build() unless that same config also enabled
events. That blocked the ordinary case of a shared configuration
carrying the URL while only some services opt in. The spec only requires
the buffer size and flush interval to be gated, which they still are.
The class-level @Getter made buffer, lock, dedupeKeys, scheduler,
inFlight, requestProcessor, logger and api public getters. Once
released, every one of them is API the SDK has to keep; the buffer
getter also handed out a list guarded by a private lock.

Only the four immutable settings stay public. The test constructor
and the scheduler/request processor accessors become package-private,
and tests read the buffer through a snapshot taken under the lock.
Traits and metadata are arbitrary caller objects, and were only
serialised when the whole batch was. A single value Jackson cannot
handle (a java.time type, a bean without properties) failed that
serialisation and dropped every event in the batch, up to 1000.

Converting traits and metadata to JSON trees at buffer time drops and
logs only the offending event. It also deep-copies them, where the
previous copy was shallow and a caller mutating a nested map could
still change an event already buffered.
While the events API is slow or down, each batch can hold a request
thread for two timeouts plus backoff, and the request processor's
queue is unbounded. Traffic kept producing batches faster than they
were given up on, so an outage grew memory with the host app's load.
A flush now drops its batch, with an error log, once ten batches are
already waiting.

The API answers 202 even when it rejects some events, listing them
under 'rejected'. Those were discarded unread; the count and the
first rejection are now logged.
- An event whose closed-check ran before close() could still land in
  the buffer after close()'s final flush and be lost unlogged. The
  check is repeated under the buffer lock, which the final flush takes
  after the flag is set.
- start() on a closed processor threw RejectedExecutionException out
  of FlagsmithClient.Builder.build(), which happens when a
  FlagsmithConfig is reused after closing a client built from it. It
  now logs and does nothing.
- build() started the flush timer before its local-evaluation checks,
  so a build that then failed left the timer running with no client to
  close it. The processor is now wired last; the offline-mode check
  moves up with the other offline checks.
- trackEvent buffered a null or blank event name, which the events API
  rejects; it now throws IllegalArgumentException, like the reserved
  '$' prefix already did. trackExposureEvent does the same for a blank
  feature name. A blank identifier is still logged and skipped, since
  an anonymous visitor is an ordinary runtime case, not a caller bug.
- withEventsMaxBufferItems(0) switched off the size trigger, and with
  the timer also off the buffer grew without bound. build() now
  rejects a limit below 1 and a negative flush interval.
- withEnableEvents(null) threw a NullPointerException from build(); it
  now leaves events disabled.
…mentFlag

FlagsmithApiWrapper.identifyUserWithTraits returns null, rather than
throwing, when the identities request times out or is interrupted.
getExperimentFlag dereferenced that null, so an API slower than the
15s future timeout surfaced as a NullPointerException and bypassed the
default flag handler.

A null result now returns the default handler's flag, with no exposure
recorded, and throws FlagsmithApiError when no handler is configured.
Capping in-flight batches over a fixed three-thread pool made the
limit a throughput ceiling of about 3 x maxBufferItems per round trip,
which throttled hardest the smaller the configured buffer: a healthy
API dropped most events at a small buffer size, and even at defaults a
burst dropped two thirds.

The cap now counts events (10,000, ten default batches), tracked under
the buffer lock and given back when a batch settles. The memory bound
at defaults is unchanged, and throughput no longer depends on buffer
size. Drops are reported at once, then at most every ten seconds with
the count accumulated in between, so a saturated caller cannot emit an
error line per flush.

The completion callback also captured the whole batch list just for
its size, keeping a second copy of every in-flight batch alive; it now
captures the int.
close() waited requestTimeoutMillis x 2, and FlagsmithConfig always
passed the SDK's default read timeout, ignoring the one the caller
configured. Even at defaults the wait was 10s against a worst case of
about 24s for one batch (connect + write + read, twice, plus backoff),
so the final batch was routinely abandoned during an outage.

The processor now derives the wait from its HTTP client: the call
timeout when set, otherwise connect + write + read, for every attempt
the retry policy allows, plus the backoff between them. With a timeout
switched off nothing bounds a request, and close() waits as long as it
does. The unreleased requestTimeoutMillis constructor parameter and
getter go, since the client already carries the timeouts. The request
processor is still shut down, not interrupted: interrupting a POST
loses its batch, where letting it finish delivers it.
The re-check that stops an event racing close() from being stranded in
the buffer had no test. A trait whose getter blocks parks the tracking
thread between the first check and the lock while close() runs its
final flush; removing the re-check makes it fail.

FlagsmithClientTest.testCloseDoesNotWedgeLaterFlushes buffered nothing
once tracking after close became a no-op, so it could not fail. The
paths it meant to cover are pinned in EventProcessorTest by
flush_completesWhenTheRequestProcessorIsAlreadyShutDown and
trackEvent_isANoOpAfterClose.
… Error

Serialising traits and metadata with valueToTree at buffer time let a
map or list that contains itself throw a raw StackOverflowError out of
trackEvent. The older flush-time serialisation had reported the same
input as a JsonMappingException, so this was a regression from moving
serialisation earlier.

Each event's traits and metadata are now written with
writeValueAsString, which reports every cycle as a checked
JsonMappingException, and are held as RawValue that Jackson emits
verbatim in the batch. The offending event is dropped and logged; the
rest are unaffected. Nothing catches Error, and the class Javadoc now
says so. Buffered JSON text is also more compact to hold than a tree.
@Zaimwa9
Zaimwa9 force-pushed the feat/experimentation-support branch from 054ef64 to d4e6185 Compare September 23, 2026 15:32
@Zaimwa9

Zaimwa9 commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

@themis-blindfold review

Comment thread src/main/java/com/flagsmith/config/FlagsmithConfig.java Outdated
@themis-blindfold

Copy link
Copy Markdown

⚖️ Themis review: 🔴 Hold the merge

The event pipeline is well-covered for a single client, but its sender is shared through a reusable configuration and gets rebound during later client builds. This can send one environment's identity event data with another environment's key. All 12 completed Java/OkHttp CI checks passed.

Area Score
🎯 Correctness 2/5
🧪 Test coverage 4/5
📐 Code quality 2/5
🚀 Product impact 4/5

🔴 Blockers

📝 Walkthrough
  • Client configuration - adds opt-in events endpoint, buffering, and flush settings.
  • Event delivery - batches custom events and experiment exposures, with retry, deduplication, and shutdown handling.
  • Flag mapping - carries remote variant, reason, and experiment metadata into public flags.
  • Request handling - adds a bounded retry policy for event batches and composable asynchronous requests.
🧪 How to verify
  1. Build two clients from one events-enabled configuration with different environment keys; record an event through the first and verify it is authenticated and delivered only to its own environment.
  2. Close either client from that setup and verify the other client can still buffer, flush, and deliver an event.
  3. Build a client with a custom API wrapper whose configuration enables events; verify timer and explicit flush both deliver events.
  4. Run mvn clean install and mvn clean install -P test-okhttp4.

Automate: add a two-client shared-configuration regression test that asserts distinct event processors and request headers.

Product take: Experiment exposure data is useful product telemetry, but misattributing it across environments corrupts results and leaks identity metadata. This is a major capability once its ownership is made client-local.

🧭 Assumptions & unverified claims

No unverified assumptions or claims.

The event buffer needs its own seat at the client table · reviewed at d4e6185

FlagsmithConfig built and held the EventProcessor, so clients sharing
one config shared a processor: the last build rebound its API key,
closing either client stopped events for both, and a custom API wrapper
with its own config left the processor in use unstarted.

The config now carries only the event settings; FlagsmithClient.build()
creates, binds and starts a processor per client from the builder's
configuration. An injected processor is still used as given.
@Zaimwa9

Zaimwa9 commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

@themis-blindfold review

Comment thread src/main/java/com/flagsmith/threads/EventProcessor.java Outdated
@themis-blindfold

Copy link
Copy Markdown

⚖️ Themis review: 🔴 Hold the merge

The default event processor is now isolated per client, but a configured custom processor is still rebound to whichever client was built last and then stopped when either client closes. The new retry policy also drops batches immediately for several 5xx responses despite promising a retry for server errors. All 12 completed test-matrix jobs passed.

Area Score
🎯 Correctness 2/5
🧪 Test coverage 4/5
📐 Code quality 3/5
🚀 Product impact 3/5

🔴 Blockers

  • src/main/java/com/flagsmith/config/FlagsmithConfig.java:343 — custom event processor sharing remains unsafe across client lifecycles.

🟠 Majors

  • src/main/java/com/flagsmith/threads/EventProcessor.java:149 — event retries omit several 5xx statuses.
📝 Walkthrough
  • Event delivery - adds an opt-in buffer, timed/max-size flushes, response rejection logging, and bounded retry handling.
  • Client lifecycle - creates a default processor per client, while custom processors remain supplied through configuration.
  • Experiment flags - maps remote variant/reason/experiment metadata and records qualifying exposures.
🧪 How to verify
  1. Run mvn -Dtest=EventProcessorTest,FlagsmithClientTest,FlagsmithRetryTest test.
  2. Make the events endpoint return 501, 505, 507, 508, 510, or 511 once, then 202; confirm the batch is posted twice.
  3. Build two clients with one custom EventProcessor and different environment keys; confirm each retains its own sender after the other client closes.
  4. Call getExperimentFlag twice for one enrolled identity and confirm exactly one $flag_exposure event is delivered per flush window.
    Automate: add the non-listed-5xx retry case and the custom-processor multi-client lifecycle case to the focused suites.

Product take: Experiment exposure data is a meaningful new capability, but incorrect environment attribution or avoidable event loss makes this unsafe to ship as-is.

🧭 Assumptions & unverified claims

No unverified assumptions or claims.

The event queue is nearly ready for its close-up; it just needs to remember which client hired it · reviewed at 05d2fc2

A processor passed to withEventProcessor was rebound by every build(),
so two clients from one config shared it: events went under the last
client's key and closing either stopped it for both. build() now claims
the processor once, atomically, and a second build throws.

Also name the exact retried statuses (500, 502, 503, 504) in the retry
policy's Javadoc instead of "a 5xx".
@Zaimwa9

Zaimwa9 commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

@themis-blindfold review

Comment thread src/main/java/com/flagsmith/threads/EventProcessor.java Outdated
@themis-blindfold

Copy link
Copy Markdown

⚖️ Themis review: 🟠 Fix before merge

The experimentation pipeline is well covered across its public APIs and lifecycle, and all completed CI jobs passed. Two delivery defects remain: the advertised 10,000-event in-flight limit can be exceeded, and several valid 5xx responses are dropped without the promised retry. Local focused tests could not be run because Maven is unavailable in this environment.

Area Score
🎯 Correctness 2/5
🧪 Test coverage 4/5
📐 Code quality 3/5
🚀 Product impact 3/5

🟠 Majors

  • src/main/java/com/flagsmith/threads/EventProcessor.java:249 — A partially filled batch can take the in-flight backlog beyond its 10,000-event cap.
  • src/main/java/com/flagsmith/threads/EventProcessor.java:151 — Non-listed 5xx responses still bypass the event retry policy.
📝 Walkthrough
  • Public client API - adds opt-in custom-event, exposure, and experiment-flag calls.
  • Event delivery - buffers, deduplicates exposures, retries sends, and flushes on a timer, capacity, or close.
  • Configuration and lifecycle - creates a separate processor per client and prevents reuse of an injected processor.
  • Flag models - carries remote experiment metadata, variants, and evaluation reasons through identity responses.
🧪 How to verify
  • Hold event requests open, flush 9,500 events, then enqueue and flush another 1,000; assert no more than 10,000 are in flight.
  • Return a 501 and a 507 from the events endpoint and assert each batch gets one retry before being dropped.
  • Exercise two clients sharing one events-enabled configuration and confirm their keys and close lifecycles stay isolated.
  • Run mvn -Dtest=EventProcessorTest,FlagsmithClientTest,FlagsmithRetryTest test.
    Automate: cover the 9,500-plus-1,000 admission boundary in EventProcessorTest.

Product take: Experiment exposure tracking is a meaningful capability, but its reliability guarantees are part of experiment quality. These two fixes matter before users rely on the resulting metrics.

🧭 Assumptions & unverified claims

No unverified assumptions or claims.

The event buffer has excellent manners, but it needs to count its guests before opening the door · reviewed at 892e05c

The in-flight guard only rejected a batch once the cap was already
reached, so a batch could push the total past it. A flush now drops its
batch when adding it would exceed the cap, but always sends when nothing
is in flight so a buffer larger than the cap still gets through.

Event batches now retry once on any 5xx, not only 500, 502, 503 and
504. The attempt count, and so the close() bound, is unchanged.
@Zaimwa9
Zaimwa9 marked this pull request as ready for review September 29, 2026 08:21
@Zaimwa9
Zaimwa9 requested a review from a team as a code owner September 29, 2026 08:22
@Zaimwa9
Zaimwa9 removed the request for review from a team September 29, 2026 08:22
@Zaimwa9

Zaimwa9 commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

@themis-blindfold review

Comment thread src/main/java/com/flagsmith/threads/EventProcessor.java
@themis-blindfold

Copy link
Copy Markdown

⚖️ Themis review: 🟠 Fix before merge

The event pipeline lets a caller disable timed flushing and configure an unbounded threshold, allowing the in-memory event buffer to grow without its 10,000-event ceiling. Enrolled identities served a disabled experiment variant also still miss their exposure event, which would skew experiment reporting. The test matrix is still running.

Area Score
🎯 Correctness 2/5
🧪 Test coverage 3/5
📐 Code quality 3/5
🚀 Product impact 3/5

🟠 Majors

  • src/main/java/com/flagsmith/threads/EventProcessor.java:334 — the configured initial-buffer path has no 10,000-event ceiling.
  • src/main/java/com/flagsmith/FlagsmithClient.java:264 — enrolled disabled variants do not record exposure events.

⚖️ Acknowledged

  • Event processors are now scoped to a single client — thread resolved by @Zaimwa9
  • Event retries now cover the full 5xx range — thread resolved by @Zaimwa9
  • The prior in-flight event cap issue was addressed — thread resolved by @Zaimwa9
  • Injected processors are claimed before polling starts — thread resolved by @Zaimwa9
  • Shutdown now derives its wait bound from configured request timeouts — thread resolved by @Zaimwa9
  • Custom Flags API headers are excluded from event requests — thread resolved by @Zaimwa9
  • Interceptors are documented as receiving event requests — thread resolved by @Zaimwa9
📝 Walkthrough
  • FlagsmithClient - adds opt-in custom-event and experiment-exposure APIs.
  • EventProcessor - serialises, deduplicates, batches and submits events independently of Flags API headers.
  • Configuration and lifecycle - adds events endpoint, buffering controls, retries and close handling.
  • Flag models - surface experiment metadata, variant and evaluation reason from remote evaluation.
🧪 How to verify
  1. Run mvn -Dtest=EventProcessorTest,FlagsmithClientTest,FlagsmithRetryTest,FeatureStateModelTest test.
  2. With a zero flush interval and withEventsMaxBufferItems(Integer.MAX_VALUE), track more than 10,000 events and confirm the buffer is capped and reports dropped events.
  3. Return enabled: false and metadata.experiment.in_experiment: true from identity evaluation, then confirm getExperimentFlag emits $flag_exposure.
  4. Exercise close with two delayed event batches and a buffered final batch; confirm all three settle within the request deadline.

Automate: add the no-timer oversized-buffer and disabled enrolled-variant regressions.

Product take: Experiment tracking expands measurement capability, but these gaps can corrupt experiment denominators and make a misconfigured high-volume client unsafe to run.

🧭 Assumptions & unverified claims
  • Maven was unavailable in this environment, so the targeted Java test command was not executed here.

A promising experiment needs a denominator and a memory ceiling · reviewed at deea76e

@Zaimwa9

Zaimwa9 commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

@themis-blindfold review

Comment thread src/main/java/com/flagsmith/threads/EventProcessor.java Outdated
@themis-blindfold

Copy link
Copy Markdown

⚖️ Themis review: 🟠 Fix before merge

TL;DR: flushEvents() is not a reliable delivery barrier when work is already queued behind the two-batch limit. All 12 completed Java/OkHttp CI jobs passed.

Area Score
🎯 Correctness 2/5
🧪 Test coverage 4/5
📐 Code quality 4/5
🚀 Product impact 4/5

🟠 Majors

  • src/main/java/com/flagsmith/threads/EventProcessor.java:244 — flushEvents() completion does not cover a batch already buffered behind the two-batch limit.

⚖️ Acknowledged

  • Keep event delivery scoped to one client — thread resolved by @Zaimwa9
  • Retry every server error promised by the event pipeline — thread resolved by @Zaimwa9
  • Keep the in-flight limit bounded — thread resolved by @Zaimwa9
  • Claim injected processors before starting local evaluation — thread resolved by @Zaimwa9
  • Honour configured event request timeouts during shutdown — thread resolved by @Zaimwa9
  • Keep Flags API custom headers off the events host — thread resolved by @Zaimwa9
  • Scope interceptor-added credentials to the events host — thread resolved by @Zaimwa9
  • Do not record exposures for disabled variants — thread resolved by @Zaimwa9
  • Preserve manual batching when callers configure it — thread resolved by @Zaimwa9
📝 Walkthrough
  • Flag and feature-state models - carry remote experiment, variant and reason data into public flags.
  • FlagsmithClient and config - add explicit event opt-in, lifecycle wiring and offline-mode protection.
  • EventProcessor - batches, serialises, retries and drains events independently of the Flags API request path.
🧪 How to verify
  1. Add a regression test with two blocked batches, a third batch already buffered, and a delayed third POST; assert flushEvents() stays incomplete until that third POST finishes.
  2. Run mvn test -Dtest=EventProcessorTest,FlagsmithClientTest,FlagsmithRetryTest,FeatureStateModelTest.
  3. Run mvn clean install.
  4. Run mvn clean install -P test-okhttp4.
    Automate: keep the two-in-flight flush-barrier case in EventProcessorTest.

Product take: This is a meaningful experimentation capability, but server-side callers need flushEvents() to be a dependable hand-off before a request or process ends.

🧭 Assumptions & unverified claims

No unverified assumptions or claims.

One last batch is still slipping past the finish line · reviewed at deea76e

When the in-flight limit is reached, flush() now also waits for the batch the current buffer will become, so its future completes once every event buffered before the call has been sent or dropped. Later batches are not awaited, as in the Node SDK.
@Zaimwa9

Zaimwa9 commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

@themis-blindfold review

@themis-blindfold

Copy link
Copy Markdown

⚖️ Themis review: 🟠 Fix before merge

The event pipeline is close, but it still permits more than the advertised 10,000 events to be retained and submitted during an outage. The recorded test matrix was still running, with no completed failures available.

Area Score
🎯 Correctness 2/5
🧪 Test coverage 4/5
📐 Code quality 3/5
🚀 Product impact 3/5

🟠 Majors

  • src/main/java/com/flagsmith/threads/EventProcessor.java — the event backlog can still exceed the promised 10,000-event cap.

⚖️ Acknowledged

  • Keep event delivery scoped to one client — thread resolved by @Zaimwa9
  • Retry every server error promised by the event pipeline — thread resolved by @Zaimwa9
  • Keep the in-flight cap at 10,000 events — thread resolved by @Zaimwa9
  • Claim injected processors before starting local evaluation — thread resolved by @Zaimwa9
  • Honour configured event request timeouts during shutdown — thread resolved by @Zaimwa9
  • Keep Flags API credentials out of the separate events host — thread resolved by @Zaimwa9
  • Keep interceptor-added credentials off the events host — thread resolved by @Zaimwa9
  • Record exposures for enrolled disabled variants — thread resolved by @Zaimwa9
  • Cap the buffer before any batch is in flight — thread resolved by @Zaimwa9
📝 Walkthrough
  • Event models - remote evaluation metadata now exposes experiment, variant and reason fields.
  • Client API - experiment lookup and custom-event methods gate event recording behind explicit configuration.
  • Event delivery - buffering, exposure deduplication, retries and shutdown draining run in the new event processor.
  • Configuration - events have a separate endpoint, configurable flush settings and a per-client processor claim.
🧪 How to verify
  1. Add a stalled-events test that accounts for both buffered and submitted batches, then assert their combined event count never exceeds 10,000 with default and large batch settings.
  2. Run mvn -Dtest=EventProcessorTest test.
  3. Run mvn -Dtest=FlagsmithClientTest,FeatureStateModelTest,FlagsmithRetryTest test.
  4. Exercise a stalled events request followed by close() and confirm all accepted batches settle within the configured request budget.
    Automate: retain a single outstanding-event counter across buffer and inFlight and assert it in the event-processor regression suite.

Product take: Experiment reporting is a solid capability addition, but its availability safeguards need to match the advertised bound before relying on it in high-volume services.

🧭 Assumptions & unverified claims

No unverified assumptions or claims.

The events queue has learned patience; it still needs to learn arithmetic. · reviewed at 7baec55

@Zaimwa9

Zaimwa9 commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

@themis-blindfold review

Comment thread src/main/java/com/flagsmith/threads/EventProcessor.java Outdated
@themis-blindfold

Copy link
Copy Markdown

⚖️ Themis review: 🟠 Fix before merge

TL;DR: The experiment event pipeline is well covered by the completed CI matrix, but a custom X-Environment-Key still overrides the key on the events request despite the documented separation of custom Flags API headers. Fix that override before merging.

Area Score
🎯 Correctness 3/5
🧪 Test coverage 4/5
📐 Code quality 4/5
🚀 Product impact 4/5

🟠 Majors

  • src/main/java/com/flagsmith/threads/EventProcessor.java:412 — custom environment-key headers can still redirect event attribution.

⚖️ Acknowledged

  • Keep event delivery scoped to one client — thread resolved by @Zaimwa9
  • Retry every server error promised by the event pipeline — thread resolved by @Zaimwa9
  • Keep the in-flight cap at 10,000 events — thread resolved by @Zaimwa9
  • Claim injected processors before starting local evaluation — thread resolved by @Zaimwa9
  • Honour configured event request timeouts during shutdown — thread resolved by @Zaimwa9
  • Keep Flags API credentials out of the separate events host — thread resolved by @Zaimwa9
  • Keep interceptor-added credentials off the events host — thread resolved by @Zaimwa9
  • Record exposures for enrolled disabled variants — thread resolved by @Zaimwa9
  • Cap the buffer before any batch is in flight — thread resolved by @Zaimwa9
📝 Walkthrough
  • Event configuration and lifecycle - adds opt-in event buffering, timer flushing, and bounded shutdown.
  • Event delivery - builds an events-only request, retries connection failures and 5xx responses, and reports rejected events.
  • Experiment flags - maps remote experiment metadata and records deduplicated exposure events for enrolled, enabled variants.
  • Client integration - creates a processor per client and keeps local evaluation, offline mode, and default handlers on their existing paths.
🧪 How to verify
  • Configure withCustomHttpHeaders with X-Environment-Key distinct from setApiKey, then confirm the events POST uses the configured API key.
  • Run mvn -Dtest=EventProcessorTest,FlagsmithClientTest,FeatureStateModelTest,FlagsmithRetryTest test.
  • Run mvn test -P test-okhttp4 to exercise the OkHttp 4 profile.
  • Exercise two clients sharing one enabled configuration and verify each POST retains its own environment key.

Automate: add the distinct custom X-Environment-Key regression to the events-header test.

Product take: Solid experimentation support, including reliable background delivery and explicit opt-in behaviour. Correct event attribution matters directly to experiment results, so the remaining header override is material.

🧭 Assumptions & unverified claims

No unverified assumptions or claims.

One header is still trying to be more helpful than invited · reviewed at 7baec55

@Zaimwa9

Zaimwa9 commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

@themis-blindfold review

Comment thread src/main/java/com/flagsmith/FlagsmithClient.java
@themis-blindfold

Copy link
Copy Markdown

⚖️ Themis review: 🟠 Fix before merge

TL;DR: The event pipeline is well covered for normal batching and shutdown paths, but rebuilding an events-enabled client from the same builder abandons a live processor. CI is still running across the supported Java and OkHttp combinations.

Area Score
🎯 Correctness 2/5
🧪 Test coverage 4/5
📐 Code quality 3/5
🚀 Product impact 3/5

🟠 Majors

  • src/main/java/com/flagsmith/FlagsmithClient.java:783 — rebuilding an events-enabled client from one builder leaves the prior processor running after close().
  • Cap each admitted event batch. Observed: a positive maxBufferItems has no upper limit and flush() moves the entire buffer into inFlight; the suite deliberately uses Integer.MAX_VALUE and 10,001 events. Predicted: one manually flushed batch could retain and submit an unbounded number of events during an events API stall. Split or reject excess before registering the batch so one batch stays within the intended 10,000-event bound.

⚖️ Acknowledged

  • Keep event delivery scoped to one client — thread resolved by @Zaimwa9
  • Retry every server error promised by the event pipeline — thread resolved by @Zaimwa9
  • Keep the in-flight cap at 10,000 events — thread resolved by @Zaimwa9
  • Claim injected processors before starting local evaluation — thread resolved by @Zaimwa9
  • Honour configured event request timeouts during shutdown — thread resolved by @Zaimwa9
  • Keep Flags API credentials out of the separate events host — thread resolved by @Zaimwa9
  • Keep interceptor-added credentials off the events host — thread resolved by @Zaimwa9
  • Record exposures for enrolled disabled variants — thread resolved by @Zaimwa9
  • Cap the buffer before any batch is in flight — thread resolved by @Zaimwa9
📝 Walkthrough
  • Event delivery - adds buffered custom events and experiment exposure reporting with retry, deduplication, and bounded queued buffering.
  • Client lifecycle - wires event ownership into client creation and shutdown, including local and offline-mode guards.
  • Evaluation models - maps remote experiment metadata, variants, and evaluation reasons into returned flags.
🧪 How to verify
  1. Build twice from one events-enabled FlagsmithClient.Builder, queue an event before the second build, then close the returned client; assert no event sender remains active.
  2. Configure maxBufferItems above 10,000, hold the events endpoint, flush, and assert the admitted batch remains bounded.
  3. Exercise an events request through a 5xx followed by a 202 and verify exactly one retry.
  4. Build two clients from the same configuration and verify each events request uses its own environment key.
  5. Run mvn clean install and mvn clean install -P test-okhttp4.

Automate: add the builder-reuse and oversized-batch regressions to the event processor suite.

Product take: Experiment exposure reporting is a meaningful capability, but lifecycle leaks or unbounded event batches can distort reporting and keep services alive unexpectedly. Fixing the two lifecycle/back-pressure edges is proportionate before enabling it broadly.

Big picture: Event processing is now owned by FlagsmithClient, while Builder can recreate and overwrite that owner on the same client instance. The first processor retains independent timer and request-executor lifecycle state, so the ownership boundary is no longer singular. Make an event-owning builder single-use, or explicitly retire the previous processor before replacement.

🧭 Assumptions & unverified claims
  • The events API's payload size and per-field limits could not be confirmed from published provider documentation; the oversized-batch finding relies on the pipeline's 10,000-event buffering intent rather than an asserted provider limit.
  • The targeted Maven tests could not be run because Maven is not installed in this environment.

A very capable event courier; it just needs one client owner at a time. · reviewed at 45804d2

@Zaimwa9

Zaimwa9 commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

@themis-blindfold review

Comment thread src/main/java/com/flagsmith/threads/EventProcessor.java
Comment thread src/main/java/com/flagsmith/threads/EventProcessor.java Outdated
@themis-blindfold

Copy link
Copy Markdown

⚖️ Themis review: 🟠 Fix before merge

The opt-in events pipeline is well covered, but two delivery paths need correction before release: recovery can exceed the caller's configured batch size, and delimiter-based exposure de-duplication can discard a distinct exposure. CI is still running; no completed check result is available yet. The focused local test suite could not be run because Maven is not installed in this environment.

Area Score
🎯 Correctness 3/5
🧪 Test coverage 4/5
📐 Code quality 4/5
🚀 Product impact 4/5

🟠 Majors

  • src/main/java/com/flagsmith/threads/EventProcessor.java:234 can send a recovery batch larger than the configured event batch size.
  • src/main/java/com/flagsmith/threads/EventProcessor.java:382 can de-duplicate two distinct exposures when caller-supplied fields contain the separator.

⚖️ Acknowledged

  • Keep event delivery scoped to one client. — thread resolved by @Zaimwa9
  • Retry every server error promised by the event pipeline. — thread resolved by @Zaimwa9
  • Keep the in-flight cap at 10,000 events. — thread resolved by @Zaimwa9
  • Claim injected processors before starting local evaluation. — thread resolved by @Zaimwa9
  • Honour configured event request timeouts during shutdown. — thread resolved by @Zaimwa9
  • Keep Flags API credentials out of the separate events host. — thread resolved by @Zaimwa9
  • Keep interceptor-added credentials off the events host. — thread resolved by @Zaimwa9
  • Bound the remaining event calls after shutdown. — thread resolved by @Zaimwa9
  • Enforce the 10,000-event in-flight bound for the first batch. — thread resolved by @Zaimwa9
  • Keep carried-batch chaining out of the stale in-flight window. — thread resolved by @Zaimwa9
  • Drain events that arrive while the in-flight cap is full. — thread resolved by @Zaimwa9
  • Record exposures for enrolled disabled variants. — thread resolved by @Zaimwa9
  • Complete flush futures for batches discarded during shutdown. — thread resolved by @Zaimwa9
  • Give shutdown enough time to drain queued event batches. — thread resolved by @Zaimwa9
  • Cap the buffer before any batch is in flight. — thread resolved by @Zaimwa9
  • Make flushEvents() wait for the buffered batch it promises to flush. — thread resolved by @Zaimwa9
  • Do not let custom headers override the events environment key. — thread resolved by @Zaimwa9
  • Do not replace a live event processor when this builder is reused. — thread resolved by @Zaimwa9
📝 Walkthrough
  • Event delivery - adds an opt-in processor with buffered custom events and experiment exposures.
  • Experiment metadata - maps remote variants, reasons, and enrolment metadata into flags.
  • Lifecycle - starts and closes the event processor with the client and local-evaluation polling.
  • Retry and shutdown - bounds event calls with a dedicated retry policy and call deadline.
🧪 How to verify
  1. Hold two event POSTs, enqueue more than withEventsMaxBufferItems, release one, and assert every recovery request contains at most the configured number of events.
  2. Record two $flag_exposure events whose identifier/value boundaries include \u0000, then assert both are sent in the same flush window.
  3. Run mvn -Dtest=EventProcessorTest,FlagsmithClientTest,FlagsmithRetryTest,FeatureStateModelTest test.
  4. Run mvn clean install for the full supported-client matrix.
    Automate: add the recovery batching and delimiter-collision cases to EventProcessorTest.

Product take: Experiment exposure data is central to measuring rollout outcomes, so silent drops or unexpectedly oversized recovery requests materially weaken the new capability. Once corrected, this is a solid addition to the SDK.

🧭 Assumptions & unverified claims

No unverified assumptions or claims.

The experiments need their receipts, not a game of delimiter hide-and-seek · reviewed at ddab0cf

The events API rejects bodies over 2 MB (axum's default limit) with a 413, which is never retried, so a 10,000-event catch-up batch would be lost whole. With the default maxBufferItems, a catch-up batch is now no larger than a normal one.
@Zaimwa9

Zaimwa9 commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

@themis-blindfold review

Comment thread src/main/java/com/flagsmith/threads/EventProcessor.java Outdated
@themis-blindfold

Copy link
Copy Markdown

⚖️ Themis review: 🟠 Fix before merge

The events backpressure path can suppress a later exposure after the original exposure has already been evicted, leaving experiment reporting without either event. CI is still running; the completed PR-title check is neutral. Local test execution was unavailable because Maven and a JDK are not installed.

Area Score
🎯 Correctness 3/5
🧪 Test coverage 4/5
📐 Code quality 4/5
🚀 Product impact 3/5

🟠 Majors

  • src/main/java/com/flagsmith/threads/EventProcessor.java:341 — an evicted exposure remains deduplicated and cannot be recorded again before the next flush.

⚖️ Acknowledged

  • Event processor ownership per client — thread resolved by @Zaimwa9
  • Retry coverage for all 5xx responses — thread resolved by @Zaimwa9
  • In-flight backlog bound — thread resolved by @Zaimwa9
  • Claim event processors before polling starts — thread resolved by @Zaimwa9
  • Shutdown timeout derived from request timeouts — thread resolved by @Zaimwa9
  • Flags API credentials excluded from events requests — thread resolved by @Zaimwa9
  • Host-scoped interceptor credentials contract — thread resolved by @Zaimwa9
  • Event calls bounded after shutdown — thread resolved by @Zaimwa9
  • First in-flight batch bound — thread resolved by @Zaimwa9
  • Atomic carried-batch chaining — thread resolved by @Zaimwa9
  • Drain buffered events when capacity returns — thread resolved by @Zaimwa9
  • Disabled experiment variants do not record exposures — thread resolved by @Zaimwa9
  • Flush futures settle during shutdown — thread resolved by @Zaimwa9
  • Shutdown drains admitted event batches — thread resolved by @Zaimwa9
  • Manual batching remains caller-configured — thread resolved by @Zaimwa9
  • flushEvents() waits for carried batches — thread resolved by @Zaimwa9
  • Custom headers cannot replace the events environment key — thread resolved by @Zaimwa9
  • Builder reuse closes the previous event processor — thread resolved by @Zaimwa9
  • Catch-up batch sizing — thread resolved by @Zaimwa9
  • Collision-free exposure de-duplication — thread resolved by @Zaimwa9
📝 Walkthrough
  • Event delivery - buffers custom events and experiment exposures, retries transient failures, and flushes on capacity, timer, or close.
  • Client API - adds opt-in event tracking, exposure tracking, explicit flushing, and experiment-aware flag lookup.
  • Flag models - preserves remote evaluation variant, reason, and experiment metadata while mapping local-evaluation reasons.
🧪 How to verify
  1. Hold both event requests open, enqueue an exposure, overflow the waiting buffer so that exposure is evicted, then enqueue the same exposure again; the later event should be delivered after release.
  2. Run mvn clean test.
  3. Run mvn clean test -P test-okhttp4.
  4. Verify an events-enabled client sends the configured environment key while omitting custom Flags API headers.
    Automate: add the overflow-and-repeat exposure case to EventProcessorTest.

Product take: Solid experimentation support, but this edge case can undercount exposures precisely during event-service backpressure, where preserving a later valid exposure matters most.

🧭 Assumptions & unverified claims

No unverified assumptions or claims.

One last queue gremlin remains in the exposure buffer. · reviewed at 960609a

@Zaimwa9

Zaimwa9 commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

@themis-blindfold review

@themis-blindfold

Copy link
Copy Markdown

⚖️ Themis review: ✅ Ship it

The events pipeline is scoped per client, keeps Flags API-only headers off the events request, and covers the important buffering, retry, shutdown, and experiment-exposure paths. CI is still running; the local Maven command is unavailable in this environment.

Area Score
🎯 Correctness 4/5
🧪 Test coverage 4/5
📐 Code quality 4/5
🚀 Product impact 4/5

⚖️ Acknowledged

  • Keep event delivery scoped to one client — thread resolved by @Zaimwa9
  • Retry every server error promised by the event pipeline — thread resolved by @Zaimwa9
  • Keep the in-flight cap at 10,000 events — thread resolved by @Zaimwa9
  • Claim injected processors before starting local evaluation — thread resolved by @Zaimwa9
  • Honour configured event request timeouts during shutdown — thread resolved by @Zaimwa9
  • Bound remaining event calls after shutdown — thread resolved by @Zaimwa9
  • Keep Flags API credentials out of the separate events host — thread resolved by @Zaimwa9
  • Keep interceptor-added credentials off the events host — thread resolved by @Zaimwa9
  • Enforce the 10,000-event in-flight bound for the first batch — thread resolved by @Zaimwa9
  • Keep carried-batch chaining out of the stale in-flight window — thread resolved by @Zaimwa9
  • Drain events that arrive while the in-flight cap is full — thread resolved by @Zaimwa9
  • Record exposures for enrolled disabled variants — thread resolved by @Zaimwa9
  • Complete flush futures for batches discarded during shutdown — thread resolved by @Zaimwa9
  • Give shutdown enough time to drain queued event batches — thread resolved by @Zaimwa9
  • Cap the buffer before any batch is in flight — thread resolved by @Zaimwa9
  • Make flushEvents() wait for the buffered batch it promises to flush — thread resolved by @Zaimwa9
  • Do not let custom headers override the events environment key — thread resolved by @Zaimwa9
  • Do not replace a live event processor when the builder is reused — thread resolved by @Zaimwa9
  • Respect the configured event batch size — thread resolved by @Zaimwa9
  • Use a collision-free exposure de-duplication key — thread resolved by @Zaimwa9
  • Do not keep de-duplication keys for evicted exposures — thread resolved by @Zaimwa9
📝 Walkthrough
  • Event configuration and client construction - adds opt-in event delivery with per-client ownership and offline-mode protection.
  • Experiment flags and payload models - carries remote experiment metadata into exposure tracking while leaving local evaluation untracked.
  • Event processor - buffers, de-duplicates, flushes, retries server failures, and bounds shutdown using the HTTP timeout budget.
  • Request layer and tests - exposes composable request futures and exercises delivery, overflow, credentials, reuse, and close races.
🧪 How to verify
  1. Run mvn -Dtest=EventProcessorTest test to exercise buffering, retries, overflow, and shutdown.
  2. Run mvn -Dtest=FlagsmithClientTest test to exercise experiment exposure and per-client lifecycle paths.
  3. Run mvn clean install -P test-okhttp4 to cover the alternate OkHttp compatibility profile.
  4. Configure two clients with separate keys and one events endpoint; verify each POST has only its own X-Environment-Key and no Flags API custom headers.

Automate: keep the events lifecycle and both OkHttp profiles in the required test matrix.

Product take: A solid SDK capability: experiment exposure and custom-event delivery become available without changing the existing default behaviour. The opt-in design and focused lifecycle safeguards make this meaningful for experimentation users.

🧭 Assumptions & unverified claims
  • The events API's field and request-size constraints were not independently verified; this review does not assert that arbitrary caller-provided event fields comply with them.

Events now know when to leave the party · reviewed at 02c8463

@gagantrivedi

Copy link
Copy Markdown
Member

@coderabbitai review

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