feat(observability-android): init observability with or without the LaunchDarkly client- #743 - #750
abelonogov-ld wants to merge 8 commits into
Conversation
…aunchDarkly client LDObserve.init took a mobile key only, so instrumenting an app that uses feature flags meant passing plugins to LDConfig, configuring telemetry through the flagging SDK's configuration. It now also accepts an already initialized LDClient, registering the plugins on it so the client's own configuration is left untouched, and both paths take the same arguments. Identity previously arrived only through the client's afterIdentify hook, which left the standalone path with no way to attribute telemetry and missed the identify LDClient performs at startup. Every identify now runs through one funnel in ObservabilityService: it caches the context keys, emits the LD.identify log and broadcasts an IdentifyEvent. The new LDObserve.identify overloads feed the same funnel, and SessionReplayHook is gone because replay collects the broadcast instead of hooking the client. Co-authored-by: Cursor <cursoragent@cursor.com>
…it runs late Touch capture and screen detection learned about activities only through ActivityLifecycleCallbacks, which are never replayed. Initializing from a background thread loses the race with the activity launch, so the launch screen produced no click spans, no screen_view, and an "unknown" screen on the initial identify until the app was backgrounded and brought back. Initialization now attaches the touch hook first thing, and where that still loses, ObservabilityService adopts the window already on screen. It resolves the window through WindowInspector rather than the decor view's context, which is a DecorContext over the application context and so never unwraps to an activity. Taps only need the window; the activity is resolved separately for the screen report. Window state is also confined to the main thread, since Session Replay enables capture from its own coroutine and Window.callback is a plain field whose cross-thread write may never become visible to the main thread. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 3 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 3b76ed7. Configure here.
| CoroutineScope(Dispatchers.Default + SupervisorJob()).launch { | ||
| replayService.identifySession(ldContext) | ||
| identify(contextKeys, ldContext.fullyQualifiedKey, attributes = null) | ||
| } |
There was a problem hiding this comment.
Seed identify can miss replay
High Severity
The initial identify is emitted on a SharedFlow with replay = 0 from a later Dispatchers.Default coroutine. extraBufferCapacity does not keep values for future subscribers, so if Session Replay's collector has not entered collect yet the seed is dropped. Standalone replays then stay attributed as unknown, which is the gap this change set out to close. The old path called identifySession on the live service directly and did not have this race.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 3b76ed7. Configure here.
| CoroutineScope(Dispatchers.Default + SupervisorJob()).launch { | ||
| replayService.identifySession(ldContext) | ||
| identify(contextKeys, ldContext.fullyQualifiedKey, attributes = null) | ||
| } |
There was a problem hiding this comment.
Seed drops context attributes
Medium Severity
seedInitialIdentify always passes attributes = null, so name and anonymous from the init LDObserveContext never reach the identify funnel. The LD.identify log and the replay identify omit those fields, and standalone sessions started with an anonymous or named context are not recorded that way.
Reviewed by Cursor Bugbot for commit 3b76ed7. Configure here.
| substitute(module("com.launchdarkly:launchdarkly-android-client-sdk")) | ||
| .using(project(":launchdarkly-android-client-sdk")) | ||
| } | ||
| } |
There was a problem hiding this comment.
Local SDK wiring breaks builds
High Severity
An unconditional includeBuild of ../../../../android-client-sdk (outside this repo), plus mavenLocal() and an AGP pin to 8.3.2, was committed. Gradle fails settings evaluation when that checkout is missing, so CI and any clean clone cannot configure the project. The PR text said this local wiring would stay out.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 3b76ed7. Configure here.


Pushed for feedback on the API shape — see the note on dependencies below before running CI. The iOS counterpart is launchdarkly/swift-launchdarkly-observability#272.
Why
LDObserve.inittook a mobile key only, so instrumenting an app that uses feature flags meant handing plugins toLDConfigand configuring telemetry through the flagging SDK's configuration.What
LDObserve.initnow also accepts an already initializedLDClient, and both paths take the same arguments:The client path registers the plugins through
LDClient.registerPlugin, so the client's own configuration is left untouched, and Observability is registered before Session Replay because replay reads theObservabilityContextthat Observability publishes.Identity now runs through one funnel. It previously arrived only through the client's
afterIdentifyhook, which left the standalone path unable to attribute telemetry (replays were recorded asunknown) and missed the identifyLDClientperforms at startup. Every identify now goes throughObservabilityService: it caches the context keys stamped onto later spans, emits theLD.identifylog, and broadcasts anIdentifyEvent. Consequences:LDObserve.identify(key, attributes)andidentify(contextKeys, canonicalKey, attributes)feed that same funnel, mirroringtrack.SessionReplayHookis deleted: replay collects the broadcast, and applies identifies idempotently (same attributes andsession_id) so a cross-platform bridge cannot double-record one.LDObserve.isFlagClientInitializedreports whether observability is attached to a client, for hosts that support both setups.The e2e app demonstrates both paths (
initWithFlagClient()/initIndependently(), selected byisIndependent) in the Compose and Java flavors, hides the LDClient-driven controls when no client is initialized, and shows identify through bothLDClientandLDObserve— including an anonymous identity that imitates whatgenerateAnonymousKeysproduces.Dependencies — CI will not build this yet
LDClient.registerPluginis not in a released Android SDK. Locally this was built against a checkout ofandroid-client-sdkcarrying that API, and the local dependency wiring is deliberately left out of this PR: nomavenLocal(), no composite build, and the client SDK pin is untouched at5.14.0, which has noregisterPlugin. Compilation fails until the client SDK ships it and the pin is bumped.Test plan
:lib:testDebugUnitTestpasses, including the newIdentifyFunnelTestandLDObserveClientInitTestisIndependent = false: flag evaluations,LDClient.identifyandLDClient.trackare instrumented and appear on the replayisIndependent = true: telemetry and the replay session are attributed toLDObserve.identify, LDClient controls are hiddenTestApplication.initForTest) still greenNote
Overview
LDObserve.initcan attach to an already-initializedLDClient(same options as standalone mobile-key init) viaregisterPlugin, deprecating wiringObservability/SessionReplaythroughLDConfig.plugins. Session replay installs directly off the publishedObservabilityContextinstead of as a second client plugin with its own hook.Identity is unified in one funnel: new
LDObserve.identify(...)APIs, anidentifyFlow/IdentifyEventbroadcast, andrecordIdentifyreplace hook-only caching.SessionReplayHookis removed; replay subscribes to the funnel and dedupes redundant identifies.LDObserve.isFlagClientInitializedlets hosts hide LDClient-only UI. Init seeds the startingLDObserveContextthrough the same path.Behavioral / reliability changes: automatic crash reporting defaults to off (opt-in via
instrumentations.crashReporting). Late init adopts the on-screen window/activity for touches and screen views; touch capture is marshaled on the main thread. Touch hook attachment happens earlier in both init paths.E2E sample app switches to
initWithFlagClient()/initIndependently()(isIndependent), documents LDObserve identify (including persisted anonymous keys), and gates flag/track/identify LDClient controls. Android client SDK pin → 5.15.0; local mavenLocal / compositeandroid-client-sdkis added for dev builds.Reviewed by Cursor Bugbot for commit 9c73b47. Bugbot is set up for automated code reviews on this repo. Configure here.