Skip to content

chore: buffer track/identify/screen calls made before initialize completes#700

Draft
mahmoud-elmorabea wants to merge 10 commits into
mainfrom
pre-init-event-buffer
Draft

chore: buffer track/identify/screen calls made before initialize completes#700
mahmoud-elmorabea wants to merge 10 commits into
mainfrom
pre-init-event-buffer

Conversation

@mahmoud-elmorabea

@mahmoud-elmorabea mahmoud-elmorabea commented May 16, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Pre-init event calls on CustomerIO.instance() are now absorbed into a bounded FIFO (capacity 100, drop-most-recent on overflow) and replayed in order once CustomerIO.initialize() completes.
  • Behavior matches the iOS sibling (customerio-ios#1054): no new API, no migration. Existing callers — CustomerIO.instance().track(...), wrapper SDK bridges, host-app cold-start code — benefit automatically. The legacy IllegalStateException thrown by pre-init instance() is gone.

This is item 2 of a cross-SDK plan to port targeted improvements from the proposed iOS SDK rewrite back to the current native SDKs.

Public API — unchanged

Symbol Behavior
CustomerIO.instance(): CustomerIO Return type unchanged. Now lazy: returns the singleton whether or not initialize() has run. Never throws.
CustomerIO.initialize(config) Unchanged signature. Now wires real components into the existing singleton instead of constructing a new one.

api/datapipelines.api diff: removes one public synthetic constructor (the compiler artifact for the now-no-arg primary constructor — the constructor was always private, so no external caller could have invoked it).

How it works

  • CustomerIO's private primary constructor is now no-arg. The heavyweight setup (Analytics instance, plugins, event-bus subscriptions, identity broadcast) moved from init {} into a synchronized initializeComponents() method called from Companion.createInstance() exactly once.
  • Runtime state (analytics, moduleConfig, globalPreferenceStore, deviceStore, contextPlugin) is lateinit and gated by an isReady/componentsReady predicate.
  • Each event-shaped *Impl method checks isReady. When ready, calls execute against analytics directly. When not ready, calls enqueue onto a PreInitEventBuffer (ReentrantLock-backed FIFO).
  • On initialize() the buffer drains synchronously against the now-ready singleton before any later caller observes the ready state — so the first post-init call cannot race ahead of buffered ones.
  • Read-side properties (userId, anonymousId, registeredDeviceToken, isUserIdentified, profileAttributes/deviceAttributes getters) return safe defaults (null / empty string / empty map) pre-init.

Scope of buffering

All event-shaped methods on DataPipelineInstance buffer when called pre-init:

  • track, identify, screen (incl. deprecated typed overloads)
  • clearIdentify, setProfileAttributes, setDeviceAttributes
  • registerDeviceToken, deleteDeviceToken, trackMetric

Decisions worth flagging

  • Buffer cap: 100. Matches the iOS sibling.
  • Overflow policy: drop most recent. Deliberate divergence from the iOS rewrite (which drops oldest). Preserves the earliest events — install attribution, initial identify — which tend to be the highest-value signals.
  • No initialize() ever called → buffer sits at cap. No TTL, no cleanup. Memory is bounded.
  • Order preserved through identify boundaries. A sequence like [identify(A), track(x), clearIdentify, identify(B), track(y)] drains in that exact order.
  • Debug-level logs on overflow drops and drain. Counter tracked across pre-init; logged once on drain.

Test plan

  • 9 new unit tests in PreInitEventBufferTest.kt: state transitions, overflow, drain order, reentrancy, concurrent enqueue, post-init direct execution, idempotent transition.
  • All existing datapipelines unit tests pass.
  • messaginginapp, messagingpush, location unit tests pass.
  • API snapshot regenerated; the only diff is removal of the compiler-synthetic constructor.
  • CI green on this PR.
  • Local wrapper validation (RN + Flutter sample apps with deliberate pre-initialize() track() call) — done locally, no PRs.

🤖 Generated with Claude Code


Note

Medium Risk
Changes the core CustomerIO singleton initialization and dispatch path, which can affect event ordering, concurrency, and identity/device-token handling during app startup.

Overview
Adds pre-initialization buffering for event-shaped SDK calls. CustomerIO.instance() is now lazily created and no longer throws pre-init; calls like track/identify/screen (and related setters/token/metric APIs) enqueue into a bounded FIFO (PreInitEventBuffer, cap 100, drop-most-recent) and replay in order once initialization completes.

Refactors initialization lifecycle and ordering. Heavy setup moves into initializeComponents(), initialize() is synchronized and idempotent, and finishInitialization() now publishes the initial UserChangedEvent after all modules have initialized and then drains the buffer; read-side accessors return safe defaults (and log debug) until ready. Adds unit tests covering buffer behavior, init idempotency, and module-subscription/event ordering; API snapshot updates by removing a synthetic constructor.

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

…etes

Replace the IllegalStateException thrown by CustomerIO.instance() pre-init
with a bounded FIFO buffer (capacity 100, drop-most-recent on overflow)
that absorbs event-shaped public-API calls and replays them in order
against the real CustomerIO once initialize() completes.

CustomerIO.instance() now returns DataPipelineInstance (super of CustomerIO)
so the same accessor can return either the buffering proxy or the real
instance. Callers that need CustomerIO-specific members (e.g. analytics,
moduleConfig) use the new CustomerIO.realInstance() opt-in API.

Closes a silent-data-loss / crash class of bugs that affected cold-start
and deep-link flows where track/identify/screen would fire before the SDK
had finished initializing. Previously those calls crashed with
IllegalStateException; they now buffer and drain.

This is the Android side of a coordinated fix; iOS sibling lands as
customerio-ios#1054.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented May 16, 2026

Copy link
Copy Markdown

Sample app builds 📱

Below you will find the list of the latest versions of the sample apps. It's recommended to always download the latest builds of the sample apps to accurately test the pull request.


Move kotlinx.coroutines.test.* imports below java.* etc. to match the
ktlint lexicographic ordering enforced in CI.
@codecov

codecov Bot commented May 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.32558% with 39 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.07%. Comparing base (b5cf24d) to head (cdd1c8d).
⚠️ Report is 11 commits behind head on main.

Files with missing lines Patch % Lines
...ines/src/main/kotlin/io/customer/sdk/CustomerIO.kt 61.05% 26 Missing and 11 partials ⚠️
.../main/kotlin/io/customer/sdk/PreInitEventBuffer.kt 97.40% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main     #700      +/-   ##
============================================
- Coverage     69.07%   68.07%   -1.00%     
- Complexity      838      887      +49     
============================================
  Files           149      156       +7     
  Lines          4601     4824     +223     
  Branches        628      673      +45     
============================================
+ Hits           3178     3284     +106     
- Misses         1189     1282      +93     
- Partials        234      258      +24     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

Copy link
Copy Markdown
  • java_layout: pre-init-event-buffer (1778946247)

@github-actions

Copy link
Copy Markdown

Build available to test
Version: pre-init-event-buffer-SNAPSHOT
Repository: https://central.sonatype.com/repository/maven-snapshots/

@github-actions

github-actions Bot commented May 16, 2026

Copy link
Copy Markdown

📏 SDK Binary Size Comparison Report

Module Last Recorded Size Current Size Change in Size
core 32.34 KB 32.34 KB ✅ No Change
datapipelines 42.39 KB 49.51 KB ⬆️ +7.12KB
messagingpush 28.93 KB 28.93 KB ✅ No Change
messaginginapp 121.84 KB 121.84 KB ✅ No Change
tracking-migration 22.89 KB 22.89 KB ✅ No Change

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: pre-init-event-buffer (1778946246)

CustomerIO.instance() now returns DataPipelineInstance instead of
CustomerIO, allowing the same accessor to return the new pre-init
buffering proxy or the real instance. Companion-class signature updated
to match.
@github-actions

Copy link
Copy Markdown
  • java_layout: pre-init-event-buffer (1778946695)

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: pre-init-event-buffer (1778946689)

… breakage

Revert the breaking change to CustomerIO.instance() return type. Existing
callers continue to receive CustomerIO and the IllegalStateException
behavior on uninitialized state is restored.

Add a new companion accessor CustomerIO.safeInstance() that returns a
DataPipelineInstance — either the real CustomerIO if initialized, or the
PreInitBufferingInstance proxy if not. The buffer mechanism remains
identical; only the surface for callers wanting non-throwing access has
moved from instance() to safeInstance().

This keeps the public API surface strictly additive:
- CustomerIO.instance(): CustomerIO  — unchanged
- CustomerIO.safeInstance(): DataPipelineInstance  — new

Wrapper SDKs (React Native, Flutter) and host-app code that fires events
during cold-start should call safeInstance() instead of instance() to
benefit from the pre-init buffer. That migration ships separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
  • java_layout: pre-init-event-buffer (1778947112)

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: pre-init-event-buffer (1778947110)

@github-actions

Copy link
Copy Markdown
  • java_layout: pre-init-event-buffer (1778947527)

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: pre-init-event-buffer (1778947517)

Customers get the buffer transparently on CustomerIO.instance().X(...) —
matching the iOS sibling's behavior. No new accessor, no migration
needed for wrappers or host apps.

How:
- CustomerIO's private constructor is now no-arg. The heavyweight setup
  (analytics, plugins, event-bus subscriptions, identity broadcast) moves
  from init {} into a synchronized initializeComponents() method called
  from Companion.initialize() exactly once.
- Runtime state (analytics, moduleConfig, globalPreferenceStore,
  deviceStore, contextPlugin) is lateinit and gated by an
  isReady/componentsReady predicate.
- Each event-shaped *Impl method (track, identify, screen,
  clearIdentify, setProfileAttributes, setDeviceAttributes,
  registerDeviceToken, deleteDeviceToken, trackMetric) checks isReady
  and either executes against analytics directly or enqueues onto
  the shared PreInitEventBuffer.
- Read-side properties (userId, anonymousId, registeredDeviceToken,
  isUserIdentified, profileAttributes/deviceAttributes getters) return
  safe defaults (null / empty string / empty map) pre-init.
- Companion.instance() is now lazy and never throws — it returns the
  singleton CustomerIO whether or not initialize() has run.
- PreInitBufferingInstance is removed (no longer needed: CustomerIO is
  its own buffering proxy).

API surface diff:
- CustomerIO.instance() return type unchanged: CustomerIO.
- safeInstance() removed (the prior interim approach is no longer needed).
- The synthetic constructor for default-args drops out because the
  primary constructor no longer has any parameters. The constructor was
  private — no external caller could ever have invoked it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
  • java_layout: pre-init-event-buffer (1778948008)

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: pre-init-event-buffer (1778948008)

Adds per-event debug logs so production diagnostics can distinguish
"buffer never received the event" from "buffer received but lost it":

- On every accept: "Pre-init event buffer accepted event (buffered count: N)."
- On every drop:   "Pre-init event buffer is at capacity (100); dropping event. Total dropped this session: N."
- On transition:   "Pre-init event buffer transitioned to ready. Drained N event(s) (dropped due to capacity this session: M)."

The transition log fires unconditionally on initialize() so customers
can confirm the drain step ran even when zero events were buffered.

All debug-level so quiet by default; surfaced when log level is bumped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
  • java_layout: pre-init-event-buffer (1779043629)

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: pre-init-event-buffer (1779043628)

@mahmoud-elmorabea mahmoud-elmorabea changed the title feat: buffer track/identify/screen calls made before initialize completes chore: buffer track/identify/screen calls made before initialize completes May 18, 2026
…rdering

Addresses PR #700 review feedback.

- `CustomerIO.finishInitialization()` now publishes the initial
  `UserChangedEvent` and drains the pre-init buffer **after** every module
  has run its `initialize()`. This means MessagingInApp (and any other
  late-subscribing module) attaches before the identity event is published,
  so a screen-heavy pre-init buffer can no longer evict it from the
  SharedFlow replay window.
- `isReady` now sources from `preInitBuffer.isReady` instead of the old
  `componentsReady` flag, so the readiness signal flips atomically with the
  drain completing. Public setters that don't synchronize via
  `DataPipelineInstance` (`setProfileAttributes`, `setDeviceAttributes`)
  therefore route through the buffer for the entire pre-init and drain
  window, preserving FIFO order with earlier buffered calls.
- Buffered closures now invoke private `*Internal` methods (identifyInternal,
  trackInternal, screenInternal, …) that contain the bodies without the
  readiness gate, avoiding recursive re-enqueue during replay. The double-
  init guard moved to a separate `initStarted` flag latched at the start of
  `initializeComponents()`.
- `PreInitEventBuffer.enqueue` now enforces capacity + drop accounting in
  the `Draining` state as well as `Buffering`. Mirrors the iOS fix; covered
  by a new `drainingStateEnforcesCapacity` unit test.
- New `FinishInitializationOrderTest` verifies that a late-subscribing
  module's `subscribe(UserChangedEvent)` happens before the initial
  publish, and that buffered screen events drain after subscription.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
  • java_layout: pre-init-event-buffer (1779104142)

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: pre-init-event-buffer (1779104156)


override fun setProfileAttributes(attributes: CustomAttributes) {
val identifier = this.userId
if (!isReady) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Let's create a dispatch helper like in customerio/customerio-ios#1054 to avoid repeating that check everywhere


override val isUserIdentified: Boolean
get() = !analytics.userId().isNullOrEmpty()
get() = if (isReady) !analytics.userId().isNullOrEmpty() else false

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These property access calls shouldn't silently return default values

data class Ready(val impl: DataPipelineInstance) : State()
}

private val lock = ReentrantLock()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Consider using kotlin's synchronized on this object instead

@mahmoud-elmorabea mahmoud-elmorabea marked this pull request as ready for review May 18, 2026 12:26
@mahmoud-elmorabea mahmoud-elmorabea requested a review from a team as a code owner May 18, 2026 12:26
Comment thread datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt Outdated
Comment thread datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt
…d-side property access; swap ReentrantLock for kotlin synchronized

Addresses PR #700 bugbot + reviewer feedback.

- Extract a `dispatch` helper that wraps the
  `if (!isReady) preInitBuffer.enqueue { … } else …Internal(…)` pattern
  used by every event-shaped public method (`setProfileAttributes`,
  `setDeviceAttributes`, `identifyImpl`, `trackImpl`, `screenImpl`,
  `clearIdentifyImpl`, `registerDeviceTokenImpl`, `deleteDeviceTokenImpl`,
  `trackMetricImpl`). De-duplicates nine copies and removes the risk of a
  new method forgetting the readiness guard.
- The helper holds `synchronized(this)` across the readiness check and
  dispatch, restoring the locking boundary that the pre-refactor
  `setProfileAttributes -> this.identify(...)` chain used to provide via
  `DataPipelineInstance`'s `synchronized { identifyImpl(...) }` wrapper.
  The lock is reentrant, so `*Impl` overrides invoked through the
  parent's synchronized helper simply re-enter without contention.
- Read-side properties (`registeredDeviceToken`, `anonymousId`, `userId`,
  `isUserIdentified`) now emit a debug log when accessed before the SDK
  is ready instead of returning their safe default silently. The
  contract (returning a default) is preserved so callers can still probe
  the singleton pre-init, but the misuse surfaces in logcat.
- `PreInitEventBuffer` switches from `java.util.concurrent.locks
  .ReentrantLock` + `withLock { … }` to a private `Any()` monitor with
  kotlin `synchronized(lock) { … }`. We never used the ReentrantLock-
  specific features (tryLock, conditions, interruptibility), and the
  built-in monitor is more idiomatic for this use case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
  • java_layout: pre-init-event-buffer (1779109155)

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: pre-init-event-buffer (1779109155)

Comment thread datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt
…e-init race

Addresses follow-up PR #700 bugbot finding.

The companion `initialize(config)` previously short-circuited on
`if (customerIO.isReady)` only. Because `isReady` flips at the very end
of initialization (`finishInitialization()`), two threads concurrently
calling `initialize(...)` could both pass `createInstance` (synchronized
on its own), both observe `isReady == false`, and both proceed to
register modules + call `finishInitialization()` — double-running
`module.initialize()` for every registered module. That double-run adds
duplicate analytics plugins and duplicate EventBus subscribers, and
publishes the initial `UserChangedEvent` twice.

- Add `@Synchronized` to the companion `initialize(config)` so concurrent
  callers serialize on the companion monitor. By the time the second
  caller acquires the lock, the first has finished
  `finishInitialization()` and `isReady` is `true` — the existing
  early-out then handles the duplicate cleanly.
- Update the `initStarted` field comment to reflect that it's the
  `initializeComponents` re-entry guard, not the companion-level guard.

Includes a new `CompanionInitializeIdempotencyTest` that invokes
`CustomerIO.initialize(...)` twice with the same counting module and
asserts the module's `initialize()` was called exactly once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
  • kotlin_compose: pre-init-event-buffer (1779110713)

@github-actions

Copy link
Copy Markdown
  • java_layout: pre-init-event-buffer (1779110726)

@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 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 d1addf2. Configure here.

Comment thread datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt
Comment thread datapipelines/src/main/kotlin/io/customer/sdk/PreInitEventBuffer.kt Outdated
…ck param

Addresses follow-up PR #700 bugbot findings.

Two related cleanups on the pre-init buffer surface:

1. Post-init event hot path no longer hits the buffer's internal lock.
   Every post-init `track` / `identify` / `screen` / etc. call goes
   through `dispatch`, which previously read `preInitBuffer.isReady` —
   a property that synchronized on the buffer's monitor to inspect its
   state. With the new `@Volatile sdkReady` field on `CustomerIO`,
   `dispatch` reads a plain volatile boolean instead. The companion
   `finishInitialization()` flips `sdkReady` to `true` *after* the drain
   completes, so concurrent callers during the drain window still see
   `false` and route through the buffer (preserving FIFO order with
   earlier buffered calls). `sdkReady` is a one-way latch, which makes
   the relaxed volatile read safe.

2. `Block` is simplified from `(DataPipelineInstance) -> Unit` to
   `() -> Unit`. Every closure enqueued by `dispatch` already captures
   its target via lexical scope (`this`); the impl argument the buffer
   threaded through `State.Ready` / `State.Draining` /
   `EnqueueOutcome.ExecuteNow` / `transitionToReady(impl)` was dead
   plumbing. Removing it shrinks the buffer's surface area and matches
   the way the rest of the SDK actually uses it. The buffer state
   machine no longer holds a reference to the SDK instance at all.

Tests are updated to capture the test `instance` via lexical scope
instead of consuming the dropped `DataPipelineInstance` parameter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
  • java_layout: pre-init-event-buffer (1779113951)

@github-actions

Copy link
Copy Markdown
  • kotlin_compose: pre-init-event-buffer (1779113937)

@hollyschilling-cio hollyschilling-cio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This doesn't serve the same purpose as it does on iOS. Specifically, on Android the initialization is entirely synchronous. Given the default installation suggested on the Quick Start Guide, this only collects events that occur before the SDK initialization begins. No Activity can be created before that point.

This also introduces potential issues. If the hosting app spawns a thread before init and that thread creates events rapidly, the draining process will hang indefinitely causing the hosting app to trigger ANR on startup. Additionally, this suppresses the previous IllegalStateException that was triggered if an event was submitted without the SDK being initialized. That means that if the SDK initialization gets removed from the app's startup sequence, there is no error to indicate the problem to the developer and events simply don't make it to the backend.

On iOS, disk operations are handled asynchronously, which necessitates an async startup. That is not the case on Android. Adding this does not solve any existing pain points on Android.

@mahmoud-elmorabea mahmoud-elmorabea marked this pull request as draft June 15, 2026 08:08
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