Skip to content

feat(observability-android): report the outcome of LDReplay.start and allow forcing past sampling - #783

Merged
abelonogov-ld merged 2 commits into
mainfrom
feat/android-replay-start-result
Sep 30, 2026
Merged

abelonogov-ld merged 2 commits into
mainfrom
feat/android-replay-start-result

Conversation

@abelonogov-ld

@abelonogov-ld abelonogov-ld commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Why

LDReplay.start() returned Unit and delegated straight to isEnabled = true. That setter short-circuits on no-change:

override var isEnabled: Boolean
    set(value) {
        if (_isEnabled.value == value) return
        ...

So a session that is enabled but not recording was unreachable. The common way to get into that state is sampling: attemptStart sets _isEnabled = true, then SessionReplaySamplingSession loses the roll and leaves _isRunning = false. Every later start() hits the guard and returns, and sampleRate is only re-evaluated after a stop() 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 a SessionReplayStartResult. Android had neither the parameter nor the result, and attemptStart(ignoreSampling:) was private.

This came up while fixing a React Native bug where configureSessionReplay({ isEnabled: false }) followed by startSessionReplay() 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.

  • New public SessionReplayStartResult — UNAVAILABLE, STARTED, ALREADY_STARTED, SAMPLED_OUT, UNRECOVERABLE_ERROR, plus isRunning. Cases and semantics match SessionReplayStartResult.swift.
  • SessionReplayService.start takes over the enable half of the isEnabled setter, 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 _isEnabled under the gate's lock, so a backend refusal racing an enable cannot be overwritten.
  • attemptStart returns the outcome instead of a bare Boolean. Each existing early return maps to the case it already represented, so the control flow is unchanged.
  • PreInitReplayBuffer now buffers the pre-init intent as Start(ignoreSampling) / Stop rather than a plain Boolean?, so a forced start issued before SDK init (e.g. from Application.onCreate) is still forced when bind applies it. Pre-init calls return UNAVAILABLE, because the outcome isn't settled until bind. Last-write-wins between start and stop is preserved.

ignoreSampling = true works even after a sampled-out decision because SessionReplaySamplingSession.shouldStartCapture checks it before decisionMade.

Compatibility

Source-compatible. The parameter is defaulted, the result can be ignored, and @JvmOverloads keeps the no-arg overload for Java. The three in-repo callers (e2e/android x2, the React Native adapter) all use start() 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:testDebugUnitTest passes (26 tests in LDReplayTest, 0 failures). Ten new cases cover: the result being forwarded from the service, SAMPLED_OUT not reporting as running, ignoreSampling defaulting to false and being forwarded when set, the isEnabled setter never ignoring sampling, a pre-init start reporting UNAVAILABLE and being replayed at init with its ignoreSampling preserved, stop winning over a buffered start, and resetForTest clearing it. A new SessionReplayStartResultTest pins the isRunning mapping that bridges depend on.

:lib:build fails lintDebug, but on five pre-existing NewApi errors in CustomSampler.kt, JsonCommonAdapter.kt, KotlinTracer.kt, and UserInteractionManager.kt — all untouched here, and lint is not part of CI. I could not run e2e/android's :app:testComposeDebugUnitTest locally (the build host ran out of disk resolving geckoview), 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 configured isEnabled on every call, so isEnabled: false plus a later start never records.


Note

Overview
LDReplay.start() now returns SessionReplayStartResult (aligned with Swift) so callers can tell whether replay actually began or was SAMPLED_OUT, UNRECOVERABLE_ERROR, etc. isRunning on that enum gives a simple boolean for bridges.

start(ignoreSampling = false) always tries to start capture, unlike isEnabled = true, which no-ops when already enabled—fixing the “enabled but not recording” case after sampleRate excludes a session. ignoreSampling = true forces recording until stop() resets sampling.

SessionReplayService exposes start(); the isEnabled setter delegates there for enable. PreInitReplayBuffer stores Start(ignoreSampling) / Stop so pre-init forced starts replay correctly at bind, returning UNAVAILABLE until then.

README documents the new return value and sampling behavior. Unit tests cover forwarding, pre-init buffering, and isRunning mapping.

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

… 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>
@abelonogov-ld
abelonogov-ld requested a review from a team as a code owner September 29, 2026 20:59
@abelonogov-ld
abelonogov-ld merged commit dd5d71c into main Sep 30, 2026
26 checks passed
@abelonogov-ld
abelonogov-ld deleted the feat/android-replay-start-result branch September 30, 2026 00:08
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>
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