Repository navigation
feat(observability-android): report the outcome of LDReplay.start and allow forcing past sampling - #783
Merged
Conversation
… allow forcing past sampling `LDReplay.start()` returned Unit and delegated to `isEnabled = true`, whose no-change guard short-circuits when replay already reports itself enabled. A session that is enabled but not recording — most commonly one `sampleRate` excluded — was therefore unreachable, and a caller had no way to tell a start that began recording from one that was sampled out or refused by the backend. `start` now takes `ignoreSampling` and returns a `SessionReplayStartResult`, matching `LDReplay.start(ignoreSampling:)` in the Swift SDK so a cross-platform bridge can report the same outcome on both platforms. - `SessionReplayService.start` replaces the enable half of the `isEnabled` setter (which now delegates to it) and deliberately drops the no-change short-circuit, so a forced start can override a sampling decision already made for the current enable cycle. - `attemptStart` returns the outcome instead of a bare Boolean. - `PreInitReplayBuffer` buffers the pre-init start intent along with its `ignoreSampling` flag, so a start issued from `Application.onCreate` is still forced when `bind` applies it. Pre-init calls report UNAVAILABLE, since the outcome is not settled until then. Existing `LDReplay.start()` callers are unaffected: the parameter is defaulted and the result can be ignored. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ntiner1
approved these changes
Sep 29, 2026
Merged
abelonogov-ld
pushed a commit
that referenced
this pull request
Sep 30, 2026
🤖 I have created a release *beep* *boop* --- <details><summary>launchdarkly-observability-android: 0.69.0</summary> ## [0.69.0](launchdarkly-observability-android-0.68.1...launchdarkly-observability-android-0.69.0) (2026-09-30) ### Features * **observability-android:** report the outcome of LDReplay.start and allow forcing past sampling ([#783](#783)) ([dd5d71c](dd5d71c)) </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] > **Overview** > This is a **Release Please** cut that publishes **`@launchdarkly/observability-android` 0.69.0** by bumping the version in `.release-please-manifest.json`, `gradle.properties`, and adding the **0.69.0** changelog entry. > > The release documents the feature from [#783](#783): **`LDReplay.start()`** now returns a **`SessionReplayStartResult`** so callers can see whether session replay actually started (e.g. sampled out, already started, errors), and supports **`ignoreSampling`** to start recording even when client sampling would normally skip capture. > > No runtime or API code changes appear in this diff—only release metadata. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 26635d9. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
abelonogov-ld
added a commit
that referenced
this pull request
Sep 30, 2026
…tializeSessionReplay (#785) ## The bug The README documents deferred recording as `isEnabled: false` plus a later `startSessionReplay()`, but that never recorded. `startSessionReplay()` re-applied the *configured* `isEnabled`, so for a deferred start it wrote the `false` straight back: - iOS — `LDReplay.shared.isEnabled = sessionReplayOptions.isEnabled` - Android — `applyEnabled(localReplayOptions.enabled)`, which calls `LDReplay.stop()` on `false` The same code is the plugin's lazy-initialization path, which is why it read the config at all: one method was doing double duty as "initialize, honoring config" and as the public "start recording". Those wants are contradictory, so this splits them. ## The fix | | before | after | | --- | --- | --- | | plugin auto-start | `startSessionReplay()` | **`initializeSessionReplay()`** — initializes, honors `isEnabled` | | public start | `startSessionReplay()` — re-applied `isEnabled` | `startSessionReplay()` — initializes if needed, then **always enables** | Neither adapter writes `false` any more; `stopSessionReplay()` is the only thing that stops recording. That also fixes a quieter bug: a re-init — a JS soft/OTA reload re-running plugin setup, for instance — used to silently stop a recording an earlier `startSessionReplay()` had begun, because the native instance outlives the JS context. `configureAndStart` feature-detects `initializeSessionReplay`, since a JS-only OTA update can run this bundle against an older native binary. Falling back to `startSessionReplay()` there is safe: honoring `isEnabled` on the first start is exactly the old binary's behavior. Both platforms now also report *why* a start didn't record, instead of always resolving as success. They call `LDReplay.start(ignoreSampling: false)` and map the result identically: `started` / `alreadyStarted` and `sampledOut` resolve — honoring `sampleRate` is not a failure — while `unavailable` and `unrecoverableError` reject. `start` is used over `isEnabled = true` because the setter's no-change guard would otherwise swallow a start on a session that is enabled but not recording. ## Dependency bumps Both are needed for `start(ignoreSampling:)` and its result enum: - iOS — `LaunchDarklySessionReplay` `~> 0.53.1` → `~> 0.56.0` - Android — `launchdarkly-observability-android` `0.66.1` → `0.69.0`, which carries [#783](#783) ## Verification - **iOS, on device**, against pod `0.56.0`. A single `configure`, then both new paths firing as designed: ``` [SR init] configure: ... enabled=false, sampleRate=1.0 LaunchDarkly Session Replay started, version: 0.56.0 [SR init] start: leaving recording off (isEnabled=false) <- init honors the config [SR init] start: already initialized, forceEnable=true <- explicit start overrides it ``` Recording was confirmed positively, not by absence of errors: a control run with `sampleRate: 0` logs `Session Replay skipped by sampling`, and that line is absent at `sampleRate: 1.0`. - **JS** — 21/21, including that the plugin calls `initializeSessionReplay` and *not* `startSessionReplay`, that `isEnabled: false` does not force recording on, and the OTA fallback. - **Android** — 10/10 against 0.69.0, including that both entry points reject a start that `configure()` never set up. One coverage gap worth flagging: the Android result mapping sits after `Handler.post`, which the package's plain JVM unit tests cannot reach, so it is not covered here. The enum-to-behavior mapping itself is covered by the core SDK's tests in [#783](#783), and iOS has no adapter-level test for its equivalent either. ## Other changes - README gains a "Delaying recording" section and an `isEnabled` row in the options table, and the `sampleRate` description no longer implies it only applies via `isEnabled`. - The example app now exercises the deferred path as a user would, replacing the configure-with-`isEnabled: true` workaround it needed before. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Fixes **deferred recording**: configuring with **`isEnabled: false`** and calling **`startSessionReplay()`** later no longer re-applies `false` and stops recording. The plugin auto-start path now calls a new **`initializeSessionReplay()`** native API (honors `isEnabled`); **`startSessionReplay()`** always turns recording on after init, without a configure round-trip. > > On **iOS and Android**, start logic no longer toggles `LDReplay.stop()` from config on re-init (which could kill an active recording after a JS soft reload). Starts use **`LDReplay.start(ignoreSampling: false)`** with mapped outcomes (sampled-out still succeeds; unavailable/unrecoverable errors reject the promise). JS **`register()`** feature-detects `initializeSessionReplay` for OTA-on-old-native fallback. > > **Dependencies**: iOS `LaunchDarklySessionReplay` **~> 0.56.0**, Android `launchdarkly-observability-android` **0.69.0**. README adds **Delaying recording**; the example app uses deferred start with a **Start replay** button. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a1dc103. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
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.
Why
LDReplay.start()returnedUnitand delegated straight toisEnabled = true. That setter short-circuits on no-change:So a session that is enabled but not recording was unreachable. The common way to get into that state is sampling:
attemptStartsets_isEnabled = true, thenSessionReplaySamplingSessionloses the roll and leaves_isRunning = false. Every laterstart()hits the guard and returns, andsampleRateis only re-evaluated after astop()resets the decision. A caller also had no way to distinguish a start that began recording from one that was sampled out or refused by the backend —start()reported nothing at all.iOS already solves both:
LDReplay.shared.start(ignoreSampling:)returns aSessionReplayStartResult. Android had neither the parameter nor the result, andattemptStart(ignoreSampling:)was private.This came up while fixing a React Native bug where
configureSessionReplay({ isEnabled: false })followed bystartSessionReplay()never records. The RN adapter needs to force a start and to report why one didn't happen, and it can't do either on Android today.What changed
LDReplay.start(ignoreSampling: Boolean = false): SessionReplayStartResult, mirroring the Swift API so a cross-platform bridge reports the same outcome on both platforms.SessionReplayStartResult—UNAVAILABLE,STARTED,ALREADY_STARTED,SAMPLED_OUT,UNRECOVERABLE_ERROR, plusisRunning. Cases and semantics matchSessionReplayStartResult.swift.SessionReplayService.starttakes over the enable half of theisEnabledsetter, which now delegates to it. It deliberately omits the no-change short-circuit, which is what makes a forced start able to override a sampling decision already made for this enable cycle. The gate check still sets_isEnabledunder the gate's lock, so a backend refusal racing an enable cannot be overwritten.attemptStartreturns the outcome instead of a bareBoolean. Each existing early return maps to the case it already represented, so the control flow is unchanged.PreInitReplayBuffernow buffers the pre-init intent asStart(ignoreSampling)/Stoprather than a plainBoolean?, so a forced start issued before SDK init (e.g. fromApplication.onCreate) is still forced whenbindapplies it. Pre-init calls returnUNAVAILABLE, because the outcome isn't settled untilbind. Last-write-wins betweenstartandstopis preserved.ignoreSampling = trueworks even after a sampled-out decision becauseSessionReplaySamplingSession.shouldStartCapturechecks it beforedecisionMade.Compatibility
Source-compatible. The parameter is defaulted, the result can be ignored, and
@JvmOverloadskeeps the no-arg overload for Java. The three in-repo callers (e2e/androidx2, the React Native adapter) all usestart()in statement position and need no change. The return type change is a binary break, so consumers recompile — normal for a minor release here.Testing
./gradlew :lib:testDebugUnitTestpasses (26 tests inLDReplayTest, 0 failures). Ten new cases cover: the result being forwarded from the service,SAMPLED_OUTnot reporting as running,ignoreSamplingdefaulting tofalseand being forwarded when set, theisEnabledsetter never ignoring sampling, a pre-init start reportingUNAVAILABLEand being replayed atinitwith itsignoreSamplingpreserved,stopwinning over a buffered start, andresetForTestclearing it. A newSessionReplayStartResultTestpins theisRunningmapping that bridges depend on.:lib:buildfailslintDebug, but on five pre-existingNewApierrors inCustomSampler.kt,JsonCommonAdapter.kt,KotlinTracer.kt, andUserInteractionManager.kt— all untouched here, and lint is not part of CI. I could not rune2e/android's:app:testComposeDebugUnitTestlocally (the build host ran out of disk resolvinggeckoview), so that job is worth watching in CI, though the call sites there are unchanged.Follow-up
This unblocks the React Native fix, which is a separate PR:
startSessionReplay()there still re-applies the configuredisEnabledon every call, soisEnabled: falseplus a later start never records.Note
Overview
LDReplay.start()now returnsSessionReplayStartResult(aligned with Swift) so callers can tell whether replay actually began or wasSAMPLED_OUT,UNRECOVERABLE_ERROR, etc.isRunningon that enum gives a simple boolean for bridges.start(ignoreSampling = false)always tries to start capture, unlikeisEnabled = true, which no-ops when already enabled—fixing the “enabled but not recording” case aftersampleRateexcludes a session.ignoreSampling = trueforces recording untilstop()resets sampling.SessionReplayServiceexposesstart(); theisEnabledsetter delegates there for enable.PreInitReplayBufferstoresStart(ignoreSampling)/Stopso pre-init forced starts replay correctly atbind, returningUNAVAILABLEuntil then.README documents the new return value and sampling behavior. Unit tests cover forwarding, pre-init buffering, and
isRunningmapping.Reviewed by Cursor Bugbot for commit 21fa7f2. Bugbot is set up for automated code reviews on this repo. Configure here.