Skip to content

feat: Android LDObserve / LDReplay thread-safe API#537

Merged
abelonogov-ld merged 15 commits into
mainfrom
andrey/maui-async-init
May 8, 2026
Merged

feat: Android LDObserve / LDReplay thread-safe API#537
abelonogov-ld merged 15 commits into
mainfrom
andrey/maui-async-init

Conversation

@abelonogov-ld
Copy link
Copy Markdown
Contributor

@abelonogov-ld abelonogov-ld commented May 8, 2026

Make LDObserve / LDReplay standalone init thread-safe and MAUI-friendly

Summary

The standalone LDObserve.init(...) and LDReplay flows 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 when LDReplay.* 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 calls LDObserve.init(...) from a background thread while the .NET runtime is still on the UI thread waiting for the bridge to return. The previous code:

  • Constructed ObservabilityService and SessionReplayService directly on the calling thread, even though both install OpenTelemetry instrumentations that touch UI / lifecycle state and must run on the main thread.
  • Published LDObserve.context = obsContext before the service was wired, leaking a partially-initialized global to any concurrent reader.
  • Wired Session Replay via register()LDReplay.init(service) → drain pre-init buffer, before SessionReplayService.initialize() had attached sessionManager, so a buffered afterIdentify would be dropped.
  • Hard-coded 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, buffering LDReplay calls so pre-init writes survive the swap, etc.).

Changes

Main-thread enforcement is now centralised and testable

  • New util/MainThread.kt exports isMainThread(), requireMainThread { ... }, runOnMainThread { ... }, postOnMainThread { ... }. All Looper-touching code in the SDK now goes through these helpers instead of hand-rolling Looper.myLooper() == Looper.getMainLooper().
  • ObservabilityService.init and SessionReplayService.initialize() now requireMainThread { ... } so misuse fails fast with a descriptive message.
  • LDObserve.init(...) (standalone path) marshals the main-thread-only construction through runOnMainThread { ... }, so callers on any thread get a synchronous, ready-to-use SDK on return without freezing the UI.
  • The helpers are now backed by a swappable MainThreadExecutor strategy (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.init is reordered to remove init races

  • context = obsContext is now published inside the main-thread block, after the underlying service is constructed and sessionManager is attached. No more partially-initialized global.
  • The body splits into two named helpers — installObservability(...) and installSessionReplay(...) — so the high-level runOnMainThread { ... } reads as intent, not mechanics.
  • For Session Replay, the plugin now exposes two distinct lifecycle phases: register(observabilityContext) only creates the SessionReplayService; initialize() then runs the service's main-thread initialize() and only after that calls LDReplay.init(service) (which drains the pre-init buffer). This guarantees sessionManager is in place before any buffered afterIdentify replays.
  • Initial identifySession(...) is launched on Dispatchers.Default from installSessionReplay so it doesn't block the looper.

LDReplay keeps caller intent across the no-op → live transition

LDReplay.start() / stop() previously hard-failed silently if called before init. Now LDReplay exposes a single var isEnabled: Boolean property (with start() / stop() as thin wrappers) and a private PreInitReplayBuffer that captures pre-init state and replays it during init:

  • isEnabled writes that arrive before the live SessionReplayService is attached are buffered and applied during init. Reads return the live service value when available, otherwise the buffered value, otherwise false.
  • 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 public LDReplay operations are now thread-safe.

SessionReplayPluginImpl (renamed from SessionReplayImpl)

  • Renamed for clarity — this is the shared implementation behind both the LDClient plugin (SessionReplay) and the standalone LDObserve.init path.
  • Dependencies are passed explicitly: register(observabilityContext: ObservabilityContext) instead of statically reading LDObserve.context. The static lookup is now confined to SessionReplay.kt (the LDClient plugin adapter), with a comment explaining why that boundary exists.
  • The duplicate-registration guard in register(...) is split into two named early-exits (same-instance vs. global-race) so the warning message tells you which one tripped.
  • initialize()'s ?: return no-op is now documented — it's intentional for the LDClient plugin path, which calls impl.initialize() from onPluginsReady regardless of whether register succeeded.

Single source of truth for the OpenTelemetry Resource

Observability.onPluginsReady and LDObserve.init were each building a near-identical OTel Resource inline (28 lines and 20 lines respectively). New client/ObservabilityResource.kt provides:

  • 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 for telemetry.distro.{name,version}. Observability.distroAttributes is now seeded from this constant; LDObserve.init calls the helper with defaults.
  • Helper takes flat parameters (no EnvironmentMetadata dependency) so the standalone path doesn't drag the LDClient SDK into client/.
  • Observability.onPluginsReady now flattens EnvironmentMetadata into named args via a tiny composeLaunchDarklySdkVersion(metadata) helper. The 28-line attribute-building block collapses to a 6-line install sequence.
  • Behaviour-preserving: produces byte-identical Resource objects 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 publishing LDObserve.context) inside a synchronous runOnMainThread block, with explicit main-thread enforcement in ObservabilityService and SessionReplayService.

Refactors Session Replay to a two-phase SessionReplayPluginImpl (register then initialize) and rewrites LDReplay around a buffered, thread-safe isEnabled/registerActivity/afterIdentify API (PreInitReplayBuffer) so pre-init calls are replayed once the live service is wired.

Deduplicates OpenTelemetry Resource construction via buildObservabilityResource/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.

(cherry picked from commit a9880a08f5b69621141d4d214076dbba1b4b562d)
(cherry picked from commit 88ca5c0ef41390d0964bb5218ea8fafc66271e7c)
@abelonogov-ld abelonogov-ld requested a review from a team as a code owner May 8, 2026 15:03
@abelonogov-ld abelonogov-ld changed the title Andrey/maui async init feat: Android LDObserve / LDReplay thread-safe API May 8, 2026
@abelonogov-ld abelonogov-ld enabled auto-merge (squash) May 8, 2026 19:57
Comment thread sdk/@launchdarkly/mobile-dotnet/sample/MauiProgram.cs
@abelonogov-ld abelonogov-ld disabled auto-merge May 8, 2026 20:01
…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)
@abelonogov-ld abelonogov-ld enabled auto-merge (squash) May 8, 2026 20:07
@abelonogov-ld abelonogov-ld disabled auto-merge May 8, 2026 20:14
Copy link
Copy Markdown

@cursor cursor Bot left a comment

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 mode 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 8d34bae. Configure here.

@abelonogov-ld abelonogov-ld merged commit 9381073 into main May 8, 2026
24 checks passed
@abelonogov-ld abelonogov-ld deleted the andrey/maui-async-init branch May 8, 2026 21:03
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 -->
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