feat: Android LDObserve / LDReplay thread-safe API#537
Merged
Conversation
(cherry picked from commit a9880a08f5b69621141d4d214076dbba1b4b562d)
(cherry picked from commit 88ca5c0ef41390d0964bb5218ea8fafc66271e7c)
Vadman97
approved these changes
May 8, 2026
…rvability-sdk into andrey/maui-async-init * 'andrey/maui-async-init' of github.com:launchdarkly/observability-sdk: feat(react-native): drop accessibilityIdentifiers options; mark minimumAlpha iOS-only (#538)
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default mode and found 2 potential issues.
❌ 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 8d34bae. Configure here.
Merged
abelonogov-ld
pushed a commit
that referenced
this pull request
May 8, 2026
🤖 I have created a release *beep* *boop* --- <details><summary>launchdarkly-observability-android: 0.46.0</summary> ## [0.46.0](launchdarkly-observability-android-0.45.0...launchdarkly-observability-android-0.46.0) (2026-05-08) ### Features * Android LDObserve / LDReplay thread-safe API ([#537](#537)) ([9381073](9381073)) </details> --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Release metadata-only update: bumps the Android package version and updates the changelog, with no code changes in this PR. > > **Overview** > Updates the release metadata to publish `sdk/@launchdarkly/observability-android` **v0.46.0** (manifest + `gradle.properties`). > > Adds the `0.46.0` changelog entry noting the new *thread-safe* `LDObserve`/`LDReplay` API. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 135df12. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Make
LDObserve/LDReplaystandalone init thread-safe and MAUI-friendlySummary
The standalone
LDObserve.init(...)andLDReplayflows had several latent thread-safety and ordering bugs that surfaced as a hard freeze on MAUI app launch (the .NET host calls into the native SDK from a background thread before the looper is fully spun up). This PR restructures both entry points so they're safe to invoke from any thread, preserves caller intent whenLDReplay.*is used before the SDK is wired up, and consolidates a few duplicated paths picked up along the way.Production behaviour is unchanged for callers that were already on the main thread. New callers (background threads, .NET MAUI bridge, future bindings) now have a defined, non-deadlocking contract.
Motivation
ObservabilityBridge.start()from .NET MAUI callsLDObserve.init(...)from a background thread while the .NET runtime is still on the UI thread waiting for the bridge to return. The previous code:ObservabilityServiceandSessionReplayServicedirectly on the calling thread, even though both install OpenTelemetry instrumentations that touch UI / lifecycle state and must run on the main thread.LDObserve.context = obsContextbefore the service was wired, leaking a partially-initialized global to any concurrent reader.register()→LDReplay.init(service)→ drain pre-init buffer, beforeSessionReplayService.initialize()had attachedsessionManager, so a bufferedafterIdentifywould be dropped.Looper.myLooper()in the main-thread guards, which made the new guards untestable on the JVM.The original symptom was a launch freeze on MAUI. Fixing it cleanly required pulling on a few threads (centralising main-thread enforcement, splitting the two
SessionReplay*lifecycle phases, bufferingLDReplaycalls so pre-init writes survive the swap, etc.).Changes
Main-thread enforcement is now centralised and testable
util/MainThread.ktexportsisMainThread(),requireMainThread { ... },runOnMainThread { ... },postOnMainThread { ... }. All Looper-touching code in the SDK now goes through these helpers instead of hand-rollingLooper.myLooper() == Looper.getMainLooper().ObservabilityService.initandSessionReplayService.initialize()nowrequireMainThread { ... }so misuse fails fast with a descriptive message.LDObserve.init(...)(standalone path) marshals the main-thread-only construction throughrunOnMainThread { ... }, so callers on any thread get a synchronous, ready-to-use SDK on return without freezing the UI.MainThreadExecutorstrategy (MainThreadExecutorHolder) — this is invisible to production code and to SDK consumers, but lets test fixtures install a synchronous executor (see "Test infrastructure" below).Standalone
LDObserve.initis reordered to remove init racescontext = obsContextis now published inside the main-thread block, after the underlying service is constructed andsessionManageris attached. No more partially-initialized global.installObservability(...)andinstallSessionReplay(...)— so the high-levelrunOnMainThread { ... }reads as intent, not mechanics.register(observabilityContext)only creates theSessionReplayService;initialize()then runs the service's main-threadinitialize()and only after that callsLDReplay.init(service)(which drains the pre-init buffer). This guaranteessessionManageris in place before any bufferedafterIdentifyreplays.identifySession(...)is launched onDispatchers.DefaultfrominstallSessionReplayso it doesn't block the looper.LDReplaykeeps caller intent across the no-op → live transitionLDReplay.start()/stop()previously hard-failed silently if called before init. NowLDReplayexposes a singlevar isEnabled: Booleanproperty (withstart()/stop()as thin wrappers) and a privatePreInitReplayBufferthat captures pre-init state and replays it duringinit:isEnabledwrites that arrive before the liveSessionReplayServiceis attached are buffered and applied duringinit. Reads return the live service value when available, otherwise the buffered value, otherwisefalse.registerActivity(activity)calls before init are buffered in submission order and replayed.afterIdentify(...)calls before init are buffered with "latest wins" semantics — older identifies are stale by definition.The buffer is documented inline (memory ordering, JMM volatile write order during
bind, why the lock is released before dispatching to the main thread). All publicLDReplayoperations are now thread-safe.SessionReplayPluginImpl(renamed fromSessionReplayImpl)SessionReplay) and the standaloneLDObserve.initpath.register(observabilityContext: ObservabilityContext)instead of statically readingLDObserve.context. The static lookup is now confined toSessionReplay.kt(the LDClient plugin adapter), with a comment explaining why that boundary exists.register(...)is split into two named early-exits (same-instance vs. global-race) so the warning message tells you which one tripped.initialize()'s?: returnno-op is now documented — it's intentional for the LDClient plugin path, which callsimpl.initialize()fromonPluginsReadyregardless of whetherregistersucceeded.Single source of truth for the OpenTelemetry
ResourceObservability.onPluginsReadyandLDObserve.initwere each building a near-identical OTelResourceinline (28 lines and 20 lines respectively). Newclient/ObservabilityResource.ktprovides:internal fun buildObservabilityResource(sdkKey, options, distroAttributes, applicationId, applicationVersion, sdkVersion): Resource— the single source of truth, with KDoc spelling out the attribute precedence.internal val DEFAULT_DISTRO_ATTRIBUTES— the canonical seed fortelemetry.distro.{name,version}.Observability.distroAttributesis now seeded from this constant;LDObserve.initcalls the helper with defaults.EnvironmentMetadatadependency) so the standalone path doesn't drag the LDClient SDK intoclient/.Observability.onPluginsReadynow flattensEnvironmentMetadatainto named args via a tinycomposeLaunchDarklySdkVersion(metadata)helper. The 28-line attribute-building block collapses to a 6-line install sequence.Resourceobjects to before.Test infrastructure
The new main-thread guards broke se
Note
Medium Risk
Touches Android SDK initialization and Session Replay control flow, including new main-thread dispatch/buffering logic, which could affect app startup and replay enablement ordering despite being well-tested.
Overview
Makes standalone
LDObserve.init(...)safe to call from any thread by marshalling main-thread-only construction (and publishingLDObserve.context) inside a synchronousrunOnMainThreadblock, with explicit main-thread enforcement inObservabilityServiceandSessionReplayService.Refactors Session Replay to a two-phase
SessionReplayPluginImpl(registertheninitialize) and rewritesLDReplayaround a buffered, thread-safeisEnabled/registerActivity/afterIdentifyAPI (PreInitReplayBuffer) so pre-init calls are replayed once the live service is wired.Deduplicates OpenTelemetry
Resourceconstruction viabuildObservabilityResource/DEFAULT_DISTRO_ATTRIBUTES, updates plugin init error handling, and refreshes tests and samples (including MAUI init and package version bump).Reviewed by Cursor Bugbot for commit 1a8807b. Bugbot is set up for automated code reviews on this repo. Configure here.