Skip to content

feat(events): summarize and buffer events on the recording thread (tier 1) - #397

Open
abelonogov-ld wants to merge 11 commits into
mainfrom
andrey/event-durability-tier1-buffer
Open

abelonogov-ld wants to merge 11 commits into
mainfrom
andrey/event-durability-tier1-buffer

Conversation

@abelonogov-ld

@abelonogov-ld abelonogov-ld commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Requirements

  • I have added test coverage for new or changed functionality
  • I have followed the repository's pull request submission guidelines
  • I have validated my changes against all supported platform versions

Related issues

Tier 1 of the Event Durability spec, "Tier 1 — the buffer". Tiers 2 and 3 are stacked on this branch and will follow as separate pull requests.

Describe the solution you've provided

DirectEventProcessor takes over from java-sdk-internal's DefaultEventProcessor in ComponentsImpl, and OutboundEventBuffer replaces the bounded queue that used to sit between the calling thread and the summarizer.

That queue is the problem this tier removes. An evaluation handed its event to an ArrayBlockingQueue and a dispatcher thread summarized it on the far side, so a burst of evaluations of untracked flags could fill the queue and displace the track and identify events an application actually asked to send. Summarizing on the recording thread means an evaluation of an untracked flag costs a counter increment and can never displace anything, because it never occupies a slot in the first place.

OutboundEventBuffer holds in full only the events that have to be sent one by one. It reuses java-sdk-internal's EventOutputFormatter and summarizer implementations rather than reimplementing them, so there stays exactly one definition of what an event looks like on the wire.

Two smaller changes ride along, both prerequisites rather than additions:

  • java-sdk-internal moves to 1.12.0, which makes the summarizer and formatter types public so the buffer can use them from its own package.
  • Diagnostic events are no longer sent while the SDK is offline or the application is backgrounded.

Describe alternatives you've considered

Reuse DefaultEventProcessor and raise its queue capacity. This moves the threshold without removing it. The displacement is a property of having a bounded handoff between the caller and the summarizer at all, and a render loop re-evaluating a tracked flag will reach any capacity you pick.

Keep the buffer inside java-sdk-internal's package to reach its package-private types. This worked, but it relied on a split package, which breaks Java modules and needed a Javadoc exclusion to build at all. Making the types public upstream was the durable fix, and is why the dependency bump is here.

Additional context

Naming. DirectEventProcessor and OutboundEventBuffer rather than an Android prefix: that prefix in this package is reserved for adapters over Android OS APIs, such as AndroidPlatformState and AndroidTaskExecutor, and neither of these classes touches one. "Direct" names the contrast with DefaultEventProcessor — nothing sits between the caller and the summarizer. DefaultEventProcessor was unavailable as a name because java-sdk-internal already exports it and this package imports from it.

Tests. DirectEventProcessorTest, EventProcessorBufferingTest and EventProcessorPrivacyTest share an EventProcessorTestBase, adding 22 tests across buffering, capacity, private-attribute redaction and diagnostics. The full unit suite is 752 tests, all passing locally.

Platform validation. The requirements box above is left unchecked deliberately. Behaviour is covered by the unit suite, but performance was measured on one physical device rather than across the supported API range.

Example app. Gains an "Eval+track+kill" button that evaluates a flag, tracks an event, flushes, and then kills the process five seconds later. That is the reproduction for the loss this work addresses, and it still loses the events on this tier — tier 3 is what makes them survive. Its LDClient.init call also moves off the deprecated three-argument overload.


Note

Overview
Replaces the Android SDK’s DefaultEventProcessor wrapper with DirectEventProcessor and OutboundEventBuffer, so flag evaluations are summarized on the recording thread and only full-fidelity events (track, identify, tracked flags, debug) use the capacity-limited buffer. That removes the old bounded inbox queue where evaluation bursts could silently drop unrelated custom events.

ComponentsImpl now builds DirectEventProcessor + OutboundEventBuffer, reusing java-sdk-internal’s wire format via launchdarkly-java-sdk-internal 1.12.0 (summarizer/formatter types made public). Diagnostics are not sent while offline or backgrounded; coming back online triggers an immediate delivery flush.

The example app switches to LDClient.init(..., INIT_WAIT_SECONDS) and adds an Eval+track+kill button to reproduce in-memory event loss before later durability tiers. New unit tests cover buffering under burst load, close/offline behavior, privacy redaction, and diagnostics.

Reviewed by Cursor Bugbot for commit 6d77bde. Bugbot is set up for automated code reviews on this repo. Configure here.

abelonogov-ld and others added 8 commits September 9, 2026 11:58
…er 1)

Tier 1 of the event durability spec: the buffer.

AndroidEventBuffer folds an evaluation into summary counters as it is recorded
and holds in full only the events that have to be sent one by one, so a burst of
evaluations of untracked flags cannot displace anything. AndroidEventProcessor
takes over from java-sdk-internal's DefaultEventProcessor in ComponentsImpl,
removing the bounded queue that used to sit between the calling thread and the
summarizer.

Spec: Event Durability, "Tier 1 — the buffer".
Co-authored-by: Cursor <cursoragent@cursor.com>
The next commit drops the split-package hack in favor of declarations that
launchdarkly/java-core#214 makes public. That is not in a release yet, so this
points the build at a working copy until one carries it.

Skipped when the directory is absent, so a clean checkout still resolves the
published artifact rather than failing. Revert together with a version bump once
the release is out.

Co-authored-by: Cursor <cursoragent@cursor.com>
…package

AndroidEventBuffer was declared in com.launchdarkly.sdk.internal.events so it
could reach the summarizers and the output formatter, which were package-private
there. That made a package split across two artifacts, forced the class to be
public for the rest of the SDK to use it, and needed a javadoc exclusion plus a
classpath workaround to keep it out of the published docs.

launchdarkly/java-core#214 makes those declarations public, so the class moves to
com.launchdarkly.sdk.android where it belongs. It is package-private now, along
with its members and Payload, because everything using it is in that package, so
it is out of the published docs by virtue of its visibility rather than by being
excluded from javadoc. The build.gradle javadoc block goes back to what it was
before tier 1.

No behavior change: same summarization, same wire format, same tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Diagnostics are meant to be off in both states, and updateScheduledTasks cancels
the periodic task accordingly, but two paths could still reach the sender after
the state had changed. Cancelling does not stop a run that has already begun, and
the init event is submitted to the executor while still online, so going offline
or backgrounding between submission and execution left it to send anyway.

Both paths now re-check on the way out, via a predicate that mirrors the
scheduling condition so the two cannot drift. The check in sendDiagnosticStats
sits ahead of createEventAndReset, which clears the counters it returns, so a
suspended period defers its statistics rather than discarding them.

Analytics delivery is unchanged: deliverPayload already skips while offline, and
it deliberately keeps running in the background so events recorded before the app
was backgrounded still get out.

Co-authored-by: Cursor <cursoragent@cursor.com>
The Android prefix in this package is reserved for adapters over Android OS APIs
- AndroidPlatformState, AndroidTaskExecutor, AndroidEnvironmentReporter. Neither
of these touches an Android API, so the prefix put them in the wrong category
while also repeating what the repository and package already say.

  AndroidEventProcessor -> DirectEventProcessor
  AndroidEventBuffer    -> OutboundEventBuffer

DirectEventProcessor names what sets it apart from java-sdk-internal's
DefaultEventProcessor: nothing sits between the caller and the summarizer.
OutboundEventBuffer says which direction the events are going, which matters
once tier 3 adds a store to read them back from.

DefaultEventProcessor was avoided as a name because java-sdk-internal already
exports that simple name and this package imports from it.

Rename only, including the test class. No behavior change.

Co-authored-by: Cursor <cursoragent@cursor.com>
- drop OutboundEventBuffer.isEmpty, which nothing calls
- StandardCharsets.UTF_8 in place of Charset.forName("UTF-8")
- scheduleWithFixedDelay, so a cached process does not come back owing every missed run
- split an over-long Javadoc sentence

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit 123cb81)
@abelonogov-ld
abelonogov-ld requested a review from a team as a code owner September 18, 2026 17:23
abelonogov-ld added a commit that referenced this pull request Sep 18, 2026
**Requirements**

- [ ] I have added test coverage for new or changed functionality —
workflow-only change
- [x] I have followed the repository's pull request submission
guidelines
- [ ] I have validated my changes against all supported platform
versions — CI will validate the configured API level

**Related issues**

Unblocks
[#397](#397). Its
CI fails during Android SDK setup.

**Describe the solution you've provided**

Upgrade `android-actions/setup-android` from v3 to v4. Licenses are
already accepted successfully; the failure happens afterward because v3
invokes `sdkmanager tools`, and Google no longer publishes that obsolete
package. Version 4 removes `tools` from its default package list while
continuing to install command-line/platform tools and accept SDK
licenses.

**Describe alternatives you've considered**

Retrying cannot fix a package removed from Google's repository. Adding a
separate license-acceptance step would duplicate behavior and would not
address the failing `sdkmanager tools` invocation inside v3.

**Additional context**

Observed error: `Warning: Failed to find package 'tools'`. The upstream
v4 change explicitly fixes this failure.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> **Fixes CI Android SDK setup** by upgrading the shared composite CI
action from `android-actions/setup-android@v3` to **v4.0.4** (pinned to
commit `be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd`).
> 
> v3 fails after license acceptance because it still runs `sdkmanager
tools`, a package Google removed from the repository (`Failed to find
package 'tools'`). v4 stops requesting that obsolete package while still
installing the command-line/platform tooling needed for `./gradlew`
builds and the rest of the workflow.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
d6a3b7e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9569078. Configure here.

abelonogov-ld and others added 2 commits September 18, 2026 15:55
Bugbot flagged that diagnosticInitSent is set without consulting the
Result. That is deliberate: DefaultEventProcessor does the same, and a
failed post returns an unsuccessful Result rather than throwing, so the
flag flips either way. Note it at the assignment so the next reader does
not read it as an oversight.

Co-authored-by: Cursor <cursoragent@cursor.com>
Going offline cancels the periodic flush, so coming back online started a
fresh interval and left whatever the outage buffered waiting for it. Each
drop re-anchored that interval, so repeated brief losses could defer
delivery well past a single one.

Co-authored-by: Cursor <cursoragent@cursor.com>
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