feat(events): summarize and buffer events on the recording thread (tier 1) - #397
abelonogov-ld wants to merge 40 commits into
Conversation
…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)
**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 -->
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>
java-sdk-internal's DefaultEventProcessor, which this path replaced, skipped the summary counter for an event marked excludeFromSummaries. OutboundEventBuffer.summarize counted unconditionally, so that flag was the one thing the replacement did not carry over; addFullEvent already honoured the sampling ratio, which is the sibling flag on the same object. Nothing observable changes today. DirectEventProcessor builds its feature events through the constructor overload that fixes both samplingRatio at 1 and excludeFromSummaries at false, so neither guard can fire through the SDK's own recording path. The point is that the two are defaulted by the same overload and would go live together: anything that moves to the fourteen-argument constructor, which adopting sampling requires, arms both at once. The failure mode is silent and arrives as wrong analytics rather than as an error, since an event carries its sampling ratio on the wire for the receiver to scale by. The guard goes in the buffer rather than in the processor because the buffer is where an event arrives from outside. A check in the processor would sit three lines below the constructor call that hardcodes the value, could never fire, and would read as dead. In the buffer it is reachable, and OutboundEventBufferFlagsTest now covers both flags -- including sampling, which had no coverage at all. Co-authored-by: Cursor <cursoragent@cursor.com>
tanderson-ld
left a comment
There was a problem hiding this comment.
This went through a multi-perspective review (general / security / adversarial), with the java-sdk-internal 1.12.0 sources as the comparison baseline. The design fundamentals held up well under adversarial scrutiny — summarizer locking, capacity enforcement under concurrency, deadlock-freedom, diagnostics state transitions, and wire-format/privacy parity were all attacked and survived — but two proven error-path regressions versus DefaultEventProcessor need fixing before this merges, and a third cluster ties into an open field issue. Inline comments mark the specific spots; summary of the findings below.
Blocking
-
A single unserializable event permanently wedges all analytics delivery.
OutboundEventBuffer.drain()restores the summarizer and never clearseventswhen serialization fails, so a deterministic failure re-fails every subsequent flush — periodic, manual, andclose()'s final delivery — while the buffer saturates and silently drops all new identify/track events and the restored summaries grow without bound. Reachable from the public API today:trackMetric(..., Double.NaN)(no finiteness check on the path; Gson's strict writer rejects it) andboolVariation(null, ...)from Java (null key poisons the summarizer itself).DefaultEventProcessorcleared the outbox before serializing: it lost one batch and recovered on the next flush. Every failure this catch block can see is deterministic (a BAOS-backed writer can't fail transiently), so the restore can only ever produce a permanent wedge — suggest discard-with-loud-log on serialization failure, or per-event quarantine. A failing regression test is available (ProofPoisonedBufferRecoveryTest). -
An
Errorduring a scheduled run silently and permanently stops all flushing.guarded()catchesException, notThrowable; anErrorescaping a periodic run (realistic:StackOverflowErrorfrom deeply nestedLDValuedata,OutOfMemoryErrorgrowing the payload stream) makes the executor cancel the task with nothing logged, andenableOrDisableTaskreturns the dead-but-non-null future forever, so no state transition revives it.DefaultEventProcessorinstalled an uncaught-exception handler that logged loudly and shut down in an orderly way; there's no counterpart here. Suggestcatch (Throwable)inguarded()plus replacing a future whoseisDone()is true. Failing regression test available (ProofFlushTaskSurvivesErrorTest). -
close()blocking and teardown — this is #281's failure mode, inherited and extended.delivery.get()is unbounded, and because all sends now share the single scheduler thread, close's delivery can wait FIFO behind an in-flight send and a queued diagnostic POST: ~3 failed sends ≈ 2 minutes at default timeouts, versus ~82 s for the old close (which never waited on diagnostics). Issue #281 is exactly this ANR shape on the current release line (main thread parked inpostMessageAndWaitunderLDClient.close()). Separately,scheduler.shutdown()→eventSender.close()doesn't quiesce the executor:shutdown()lets queued tasks run, so a delivery enqueued by a racingsetOffline(false)(its submit doesn't re-checkclosed), or a still-running delivery abandoned by an interrupted wait, executes against the closed sender. Suggested as one change: boundedget(timeout)with a logged give-up,shutdownNow()+ boundedawaitTermination()beforeeventSender.close(), and aclosedre-check before thesetOfflinecatch-up submit. (Note: timedgeteverywhere — untimedgeton a task discarded byshutdownNow()hangs forever.) With tier 3 durability this wait stops being load-bearing entirely; bounding it now would let this work close out #281 rather than carry it forward.
Strongly recommended
-
Serialization runs inside the monitor every evaluation contends on.
drain()holds the buffer lock across the full JSON encode (logger.warnon the drop path is under it too), so recording threads — often the main thread — block behind a flush's encoding: measured ~22 ms cold for 100 events, and the work scales with accumulated summary contexts, notcapacity. Fix carefully: the wide lock is what makes the failure path atomic today (restoreToreplaces rather than merges), so snapshot-under-lock/encode-outside needs a merging restore upstream first. -
capacityno longer bounds event-subsystem memory. The per-context summarizer is unbounded (one entry per distinct context, full attributes retained) and grows from construction whileinitiallyOffline. Pre-existing in degree, but the inbox backpressure that used to shed bursts is gone, and finding 1 turns "bounded by outage duration" into "bounded by process lifetime." Cap it (counting overflow intodroppedEventCount) or state explicitly that a later tier owns it. -
An evaluation's summary/full/debug writes are three separate lock acquisitions. A flush can interleave between them (summary in payload N, feature event in N+1), and a
close()interleaving can deliver the summary while silently stranding the feature event. Onebuffer.record(event, requireFullEvent, debugEvent)call restores the atomicity the old single-dispatcher design had, and cuts hot-path lock traffic 3×. -
A diagnostics period can be destroyed in exactly the way the comment says it can't.
sendDiagnosticStatschecks suspension, then destructively reads (getAndClearDroppedCount,createEventAndReset), thensendDiagnosticEventre-checks suspension and can drop the built event — an offline/background transition in that window discards the period's stats and skewsdataSinceDate. Drop the inner re-check for the stats path or re-inject on suppression. -
capacity(0)now silently drops every full-fidelity event. The>= 0 ? capacity : 1normalization is copied fromEventsConfiguration(whose javadoc says zero-or-negative → 1), but on mainnew ArrayBlockingQueue<>(0)crashed init loudly, masking the off-by-one; that mask is gone with the inbox. Use> 0, and consider a builder-level floor. -
Test gaps sit in precisely the new risk areas.
diagnosticInitEventIsSentWhenComingOnline's only-one-init assertion never exercises the guard (the fixture makessetInBackground(false)a no-op transition);diagnosticInitEventIsNotSentWhileOfflinewaits for the init to be delivered before going offline; no test flushes concurrently with recording (the one race this design introduces, and the regression test a future lock-narrowing needs); and the injectable constructor is never used, leaving 401/mustShutDown, dropped-count-to-diagnostics accounting, and periodic stats uncovered. The twoProof*tests above are written as regression tests (they pass once 1 and 2 are fixed) and are available to include.
Worth stating what's good here too: privacy redaction is byte-for-byte parity with genuinely thorough new tests; capacity accounting, sampling, debug-event TTL, and wire ordering all verified equivalent; close() fixes a scheduler-thread leak the old wiring had on every init/close cycle; and blockingFlush() now actually matches its documented semantics.
| * {@code finish()} or backgrounding would run the SDK's background flush, so this uses | ||
| * {@link android.os.Process#killProcess}. | ||
| */ | ||
| private void setupKillUnsentButton() { |
There was a problem hiding this comment.
I don't think this should be committed. This example isn't meant to be about edge cases we are expected to handle but more about public API usage.
There was a problem hiding this comment.
Moving into separate test-app
There was a problem hiding this comment.
In the event processor on main, the flushing thread that handled events was separate from what handled diagnostics. Now it is possible for a slow diagnostics task to delay the event flushing task. This shouldn't be possible as diagnostics is a secondary concern to event reporting throughput. May just be worth mirroring the separation on main to maintain parity.
There was a problem hiding this comment.
Diagnostic Events now use independent executor as in Java Server
| // Set when the service tells us to stop, e.g. because the mobile key is invalid. | ||
| private final AtomicBoolean disabled = new AtomicBoolean(false); | ||
| private final AtomicBoolean diagnosticInitSent = new AtomicBoolean(false); | ||
| private final AtomicLong lastKnownPastTime = new AtomicLong(0); |
There was a problem hiding this comment.
It looks like only closed actually needs to be an atomic boolean? Doesn't hurt / cost too much for them to be, just pointing it out.
There was a problem hiding this comment.
lastKnownPastTime will matter in tier-3 other 2 fixed
| Future<?> delivery = submit(this::deliverPayload); | ||
| if (delivery != null) { | ||
| try { | ||
| delivery.get(); |
There was a problem hiding this comment.
close() can block ~50% longer than the wait that's already generating field ANRs (#281) — suggest bounding it
close() waits on delivery.get() with no timeout (DirectEventProcessor.java:201). Since the single-threaded scheduler runs every send FIFO, that wait covers, worst case: the currently-running send (cancel(false) doesn't stop a body already executing — and that body can now be a diagnostic POST, not just analytics), any queued diagnostic-init send from a recent online/foreground transition, and then the final delivery itself. At default config each failed send costs ~41 s (2 attempts × (10 s connect + ~10 s read) + 1 s retry in DefaultEventSender), so the stacked worst case is ~2 minutes, against ~82 s for the code this replaces — DefaultEventProcessor.close() waited only on analytics flush workers (diagnostics ran on sharedExecutor and were never on the close path), and its 1-slot payload queue capped the backlog at two sends. Even the mildest single-timeout case (~10 s) exceeds the ANR threshold when close() runs on the main thread.
To be clear about the half this PR gets right: the old close could also return instantly under inbox pressure, silently dropping the final flush — this PR's "the caller is entitled to assume the events made it out" semantics and interruptible wait are genuine improvements. The problem is only that the wait is unbounded and its worst case grew.
This matters beyond theory: #281 (open) is this exact failure on the current release line — main thread parked in postMessageAndWait's semaphore under LDClient.close(), at scale, on Android 14 devices with half-open networks. This PR inherits that failure mode and lengthens the chain by moving diagnostic sends onto the same thread as the close-path delivery.
Suggestions, separable:
- delivery.get(timeout, unit) with a logged give-up — a few seconds is a defensible budget for a last-chance delivery from a lifecycle callback.
- Keep diagnostic sends from queueing ahead of close's delivery (or cap diagnostics at a single attempt, no retry — they're best-effort telemetry).
There was a problem hiding this comment.
Both are done
| } | ||
| } | ||
| scheduler.shutdown(); | ||
| eventSender.close(); |
There was a problem hiding this comment.
I don't think shutdown guarantees other race conditions haven't scheduled work (e.g. a simultaneous thread in setOffline).
There was a problem hiding this comment.
It is become different after diagnostic separation work
| } | ||
| // Flushing is pointless while we are offline, but it stays on in the background so that | ||
| // events recorded before the app was backgrounded still get delivered. | ||
| flushTask = enableOrDisableTask(!offline, flushTask, flushIntervalMillis, |
There was a problem hiding this comment.
Don't you want to be willing to disable tasks independent of if the instance is closed?
There was a problem hiding this comment.
Good point. Cancelling no longer depends on the closed check: updateScheduledTasks now uses closed only to decide whether to start work, and close() cancels by calling it under stateLock instead of doing its own cancel calls.
There was a problem hiding this comment.
Having closed checks throughout many functions of a class that can run in a multithreaded environment is usually an indication of code smell. The closed checks may prevent you from doing work that was not desired, but they don't protect against two threads getting past those closed checks, so you end up needing proper locking anyways. There appear to be a few of these race conditions in this code.
Closed checks can work nicely in a single threaded region or single isolate language though.
There was a problem hiding this comment.
Agreed, and I cut them down. Two entry-point checks in setOffline and setInBackground were redundant and are gone
| new OutputStreamWriter(buffer, StandardCharsets.UTF_8), INITIAL_OUTPUT_BUFFER_SIZE); | ||
| int outputEventCount; | ||
| try { | ||
| outputEventCount = formatter.writeOutputEvents(eventsOut, summaries, writer); |
There was a problem hiding this comment.
serialization happening while holding class lock via due to synchronized.
…/sdk/android/DirectEventProcessor.java Co-authored-by: Todd Anderson <127344469+tanderson-ld@users.noreply.github.com>
Recording an event and encoding a batch of them were sharing one monitor. Encoding is the dominant cost on the path and runs on the events executor; recording runs on whichever thread evaluated a flag, which on Android is usually the main one. An evaluation could therefore wait out a whole batch encode before it was allowed to add a counter. The list of full events, the capacity counted against it and the drop count move to DirectEventProcessor. A delivery takes the run under pendingLock and encodes it after releasing, so a recording thread waits only for another thread's memory operation. OutboundEventBuffer keeps the counters and the wire format, and takes the summaries under its own lock for the same reason. A run that cannot be serialized is now discarded rather than put back. Nothing the encoder can fail on is transient -- it writes to a byte array -- so a restored run would have failed again on every later flush and no events would ever have been delivered again. EventProcessorTestBase now waits out the delivery that coming online schedules. It was previously free to land in the middle of a test's recording loop and take a batch the test had not asked to send, which at capacity reads as the limit having been exceeded. Co-authored-by: Cursor <cursoragent@cursor.com>
Nothing in it logs any more. The capacity warning went to the processor with the capacity check, and a failed encode is thrown to the caller rather than reported here. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* main: test(fdv2): stop requiring a changeset to be the first result
Co-authored-by: Cursor <cursoragent@cursor.com>
disabled and diagnosticInitSent are one-way latches that only ever get set once and read; neither needs an atomic. Leaving them as AtomicBoolean obscured the one flag in the class that does need it, closed, whose CAS is what makes close() idempotent. lastKnownPastTime stays atomic but now moves forwards only. Analytics and diagnostic responses are handled on separate threads since diagnostics got their own executor, so a plain assignment let an older reading of the service clock overwrite a newer one. Co-authored-by: Cursor <cursoragent@cursor.com>
The scheduling state machine was already correct: close() sets the flag before taking stateLock and cancels under it, so whichever side reaches the lock second sees the other's work. setInBackground and setOffline were re-testing the flag on the way in, which added nothing; the check inside updateScheduledTasks is the one that decides, and now says so. record() tested the flag outside pendingLock, so a record could interleave with close()'s final delivery and leave an event in a list nothing would drain again. The test now happens under the lock that deliverPayload takes to lift the run out, which orders the two. submit() and close()'s teardown now share submitLock, so a flush cannot land between the sender release being queued and the executors being shut down, where it would have been queued behind the release. Co-authored-by: Cursor <cursoragent@cursor.com>
The per-context summarizer keeps a counter set per context, keyed on the whole context with every attribute retained, and capacity did not reach it: 5,000 offline identify-and-evaluate cycles retained 5.3 MB with capacity set to 100. Nothing drains it while the client is offline, which is exactly when an application is free to go on evaluating, so the only bound was how long the outage lasted. Capacity now bounds the cardinality as well. Only a context that is not already being counted can be turned away, so an application evaluating against one context pays nothing for the limit however many evaluations it does, and a delivery makes room for the next set. A refused evaluation is counted into the dropped total diagnostics carry, because nothing later reconstructs a counter. Co-authored-by: Cursor <cursoragent@cursor.com>
One helper for both, so a reset path that cleared the counters alone cannot spend the cardinality limit on contexts whose counters have already gone out. Co-authored-by: Cursor <cursoragent@cursor.com>
An evaluation of a tracked flag writes a summary counter and a feature event, and a debug event alongside them when debugging is on. Those writes took separate locks, so a close() landing between them delivered the counter and then refused the event, reporting an evaluation that no event describes. Widen the lock that guarded the pending events to cover the summary counters too, so the writes one evaluation makes are one critical section. drain() splits into takeSummaries() and encode() so the flush can take the run and the counters together and still encode outside the lock. Co-authored-by: Cursor <cursoragent@cursor.com>
createEventAndReset hands back a period's statistics and clears them in the same call, so whoever builds the event owns it. It was built on the scheduler thread and posted from the diagnostics thread, which may have been busy with an earlier post; a transition to offline or background in that window was noticed after the build and discarded the event. The period's stream inits, events-in-batch count and dropped count went with it, and dataSinceDate had already moved, so the next event described a window beginning after the reset. Move the build onto the posting thread, behind the suspension check, so a transition leaves the counters where they are for the next period to carry. The init event keeps its check, having nothing to consume. Co-authored-by: Cursor <cursoragent@cursor.com>
The normalization said `>= 0`, so a capacity of nought stayed nought. On main that threw from `new ArrayBlockingQueue<>(0)` at construction; with the pending list there is nothing to throw, so it became a silent refusal of every event. Worse than it sounds, because the same number is the buffer's context limit and only the processor was normalizing it: at nought neither full events nor summaries survived, and at -5 the processor allowed one event while the buffer refused to summarize at all. So the fix is one normalization in ComponentsImpl feeding both, with the processor keeping its own guard for direct construction. Co-authored-by: Cursor <cursoragent@cursor.com>
…ing the server describeConfiguration was sending the raw capacity, so a client configured with nought told the service nought while running at one. It now shares the one normalization with the buffer and the processor. The race test was flaking about one run in six. Not the processor: the test server records a request body with a single unlooped read, so a body past one socket read's worth comes back truncated, at a point that moves between runs. Thirty events instead of a hundred and fifty stays clear of it, and the test still fails on every un-fixed run. Co-authored-by: Cursor <cursoragent@cursor.com>
Three components bound themselves by this number and each was normalizing it, or not. Clamping in the setter makes the field valid by construction, which is what the sibling setter for the diagnostic interval already does, and leaves every consumer -- the buffer, the processor, the diagnostic description -- to just use it. Co-authored-by: Cursor <cursoragent@cursor.com>
The two init tests asserted nothing. One asked a foreground processor to go to the foreground, which setInBackground returns from before reaching any decision; the other waited for the init event before going offline, so the only thing stopping a second one was that the first had already gone. Each was held up by the guard the other claimed to cover, and both passed with every guard deleted. Coming to the foreground now goes through the background first, and waits for the diagnostics thread to come free -- while the first init is still posting, the claim is taken and the transition is turned away before it decides anything. The offline one builds a processor that never comes online, then brings it online to show the silence was the offline state and not a dead fixture. Two new ones. A must-shut-down response has to stop recording and delivery for good, which nothing exercised. And a flush running against four recorders has to keep each evaluation in one payload: close() delivers once, so it can catch an evaluation being stranded but never one being split across two deliveries. Eight trials, because a single one catches a split lock about two times in three. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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 38337ab. Configure here.
The MalformedJsonException in aContextAlreadyBeingCountedKeepsCountingOnceTheLimitIsReached is not a serialization race: recording is sequential on the test thread and the payload is a deterministic ~28 KB. The test-helpers HTTP server reads request bodies with a single unlooped read, so larger bodies can be truncated depending on the machine. Fill the summarizer to a limit of 10 contexts instead of 100 so each flush stays well under that size. Co-authored-by: Cursor <cursoragent@cursor.com>
Offline means the application told the SDK to stay off the network, and closing does not revoke that, so the final delivery in close() is skipped and held events are dropped. DefaultEventProcessor behaved the same way (its FLUSH is a no-op while offline); the comments and the EventProcessor javadoc promised more than either implementation did. Add a test pinning the behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
…n reconnect The SDK builds the event processor offline and turns it on once initialization is done. Coming online used to deliver immediately, meant for the end of an outage, but it also fired on that first transition at startup and sent the initial identify alone before anything else was recorded. DefaultEventProcessor never did that, and the contract tests expect the first payload to wait for a flush. The immediate delivery existed only because cancelling the periodic flush for an outage restarted its interval on every reconnect. The flush task now stays scheduled regardless of the offline state (a run while offline does nothing), so the interval is never restarted and what an outage buffered goes out at the first run after it ends. No transition needs special handling. Co-authored-by: Cursor <cursoragent@cursor.com>
updateScheduledTasks no longer returns early once the processor is closed. The closed flag now gates only starting work, so cancelling always happens, and close() cancels its tasks by calling updateScheduledTasks under stateLock instead of repeating the cancel calls itself. Co-authored-by: Cursor <cursoragent@cursor.com>
- Wrap recordEvaluationEvent, recordIdentifyEvent, recordCustomEvent, and record in try/catch to ensure unexpected telemetry failures never crash user application calls. - Reset summaryContextsExceeded when taking summaries during a flush run so subsequent runs that exceed the context limit log the warning. - Add unit tests confirming both behaviors. Co-authored-by: Cursor <cursoragent@cursor.com>
- Catch RuntimeException rather than Throwable on the recording path. It runs on the application's thread, so an Error such as OutOfMemoryError now propagates to the application's crash reporting instead of being logged and swallowed. - Drop the outer try in recordIdentifyEvent and recordCustomEvent; record() already handles its own failures. - Claim the context-limit warning under recordLock, the lock the delivery resets it under. Claimed outside it, a delivery landing in between let one run's overflow suppress the next run's warning. - Add a test that an Error while recording reaches the caller. Co-authored-by: Cursor <cursoragent@cursor.com>
| @@ -88,64 +126,190 @@ synchronized void summarize(Event.FeatureRequest event) { | |||
| event.getDefaultVal(), | |||
| event.getContext() | |||
| ); | |||
There was a problem hiding this comment.
I am just now realizing the context hash done by !countedContexts.contains(event.getContext()) and by summarizeEvent for equality is not insignificant when compared to serialization of events. This PR is the first time LDContext hashing is being done on the hotpath.
Based on my local benchmarking, for the canonical single-context Android app, hashing overtakes serialization at 5.3 evaluations per 30-second window — one eval every ~5.6 seconds. Any app that evaluates a flag even once per screen render is thousands of times past the crossover where context equality hashing dominates CPU.
Have you investigated / measured this component of CPU usage?
| * contexts are counted at once, because each one costs a retained context and its own counters. | ||
| * | ||
| * @param event the evaluation | ||
| * @return false if this evaluation was not counted, because counting it would have meant holding |
There was a problem hiding this comment.
Should read false if this event was not counted

Requirements
Related issues
Fixes #281.
DefaultEventProcessor.close()waited with no limit for its final flush, which on a slow network blocked the calling thread long enough to trigger an ANR.DirectEventProcessor.close()waits at most two seconds for that delivery and then lets it finish in the background.Tier 1 of the Event Durability spec, "Tier 1 — the buffer". Tier 2 is stacked on this branch as #403; tier 3 will follow.
Describe the solution you've provided
DirectEventProcessortakes over from java-sdk-internal'sDefaultEventProcessorinComponentsImpl, andOutboundEventBufferreplaces 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
ArrayBlockingQueueand a dispatcher thread summarized it on the far side, so a burst of evaluations of untracked flags could fill the queue and displace thetrackandidentifyevents 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.OutboundEventBufferholds in full only the events that have to be sent one by one. It reuses java-sdk-internal'sEventOutputFormatterand 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:
Describe alternatives you've considered
Reuse
DefaultEventProcessorand 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.
DirectEventProcessorandOutboundEventBufferrather than anAndroidprefix: that prefix in this package is reserved for adapters over Android OS APIs, such asAndroidPlatformStateandAndroidTaskExecutor, and neither of these classes touches one. "Direct" names the contrast withDefaultEventProcessor— nothing sits between the caller and the summarizer.DefaultEventProcessorwas unavailable as a name because java-sdk-internal already exports it and this package imports from it.Tests.
DirectEventProcessorTest,EventProcessorBufferingTestandEventProcessorPrivacyTestshare anEventProcessorTestBase, 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.initcall also moves off the deprecated three-argument overload.Note
Overview
Replaces the Android SDK’s analytics pipeline wrapper around java-sdk-internal’s
DefaultEventProcessorwithDirectEventProcessorandOutboundEventBuffer, so flag evaluations are summarized on the recording thread and only full-fidelity events consume buffer capacity. This removes the old bounded inbox queue that could drop summaries or crowd outtrack/identifyduring evaluation bursts.ComponentsImplnow buildsDirectEventProcessorwith a dedicated diagnostics posting thread, a 2s close budget for final delivery (then continues in the background), and stricter shutdown/offline rules documented onEventProcessor.EventProcessorBuilder.capacityclamps values below 1 to 1.launchdarklyJavaSdkInternalis bumped to 1.12.0 so summarizer/formatter types can be reused from the new buffer.The example app switches to blocking
LDClient.init(..., INIT_WAIT_SECONDS)instead of awaiting aFuture. A new internaltest-appmodule (dexcount kept there) adds an Eval+track+kill harness for unsent-event scenarios; example drops dexcount..gitignorenow ignoreslocal.propertieswithout a leading slash.Tests: large new suite (
DirectEventProcessorTest, buffering/privacy/flags tests, sharedEventProcessorTestBase) covering atomic record/flush, capacity, diagnostics, and redaction through the new path.Reviewed by Cursor Bugbot for commit 15d3ffa. Bugbot is set up for automated code reviews on this repo. Configure here.