# LaunchDarkly Observability SDK for Android ## Early Access Preview️ **NB: APIs are subject to change until a 1.x version is released.** ## Features ### Automatic Instrumentation The Android observability plugin automatically instruments: - **Activity Lifecycle**: App lifecycle events and transitions - **App Lifecycle**: Emits `app_foreground` / `app_background` spans on foreground/background transitions, plus matching Session Replay `Foreground` / `Background` breadcrumbs (gate the span via `analytics.appLifecycle`) - **Screen Views**: Emits a `screen_view` span for each Android `Activity` that is shown - **Taps**: Optionally emits a `click` span for each tap (enable via `analytics.taps`) - **HTTP Requests**: OkHttp and HttpURLConnection requests (requires setup of ByteBuddy compile time plugin and additional dependencies) - **Crash Reporting**: Automatic crash reporting and stack traces - **Feature Flag Evaluations**: Evaluation events added to your spans. - **Session Management**: User session tracking and background timeout handling ## Example Application A complete example application is available in the [e2e/android](../e2e/android) directory. ## Install Add the dependency to your app's Gradle file: ```kotlin dependencies { implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.+") implementation("com.launchdarkly:launchdarkly-observability-android:0.19.1") } ``` ## Usage ### Basic Setup Add the observability plugin to your LaunchDarkly Android Client SDK configuration: ```kotlin import com.launchdarkly.observability.plugin.Observability import com.launchdarkly.sdk.android.LDConfig import com.launchdarkly.sdk.android.Components import com.launchdarkly.sdk.android.LDClient class MyApplication : Application() { override fun onCreate() { super.onCreate() val mobileKey = "your-mobile-key" val ldConfig = LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Enabled) .mobileKey(mobileKey) .plugins( Components.plugins().setPlugins( listOf( Observability(this@MyApplication, mobileKey) ) ) ) .build() val context = LDContext.builder(ContextKind.DEFAULT, "user-key") .build() LDClient.init(this@MyApplication, ldConfig, context) } } ```
Java The SDK is written in Kotlin but is fully usable from Java. `Observability` and `SessionReplay` accept the same options objects; use the `*.builder()` factories described below to configure them. ```java import com.launchdarkly.observability.plugin.Observability; import com.launchdarkly.sdk.ContextKind; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.Components; import com.launchdarkly.sdk.android.LDClient; import com.launchdarkly.sdk.android.LDConfig; import com.launchdarkly.sdk.android.integrations.Plugin; import java.util.Collections; public class MyApplication extends Application { @Override public void onCreate() { super.onCreate(); String mobileKey = "your-mobile-key"; LDConfig ldConfig = new LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Enabled) .mobileKey(mobileKey) .plugins( Components.plugins().setPlugins( Collections.singletonList( new Observability(this, mobileKey) ) ) ) .build(); LDContext context = LDContext.builder(ContextKind.DEFAULT, "user-key") .build(); LDClient.init(this, ldConfig, context); } } ```
### Configure additional instrumentations To enable HTTP request instrumentation and user interaction instrumentation, add the following plugin and dependencies to your top level application's Gradle file. ```java plugins { id 'net.bytebuddy.byte-buddy-gradle-plugin' version '1.+' } dependencies { // Android HTTP Url instrumentation implementation 'io.opentelemetry.android.instrumentation:httpurlconnection-library:0.11.0-alpha' byteBuddy 'io.opentelemetry.android.instrumentation:httpurlconnection-agent:0.11.0-alpha' // OkHTTP instrumentation implementation 'io.opentelemetry.android.instrumentation:okhttp3-library:0.11.0-alpha' byteBuddy 'io.opentelemetry.android.instrumentation:okhttp3-agent:0.11.0-alpha' } ``` ### Advanced Configuration You can customize the observability plugin with various options: ```kotlin import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.sdk.android.LDAndroidLogging import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.api.common.Attributes import kotlin.time.Duration.Companion.minutes val mobileKey = "your-mobile-key" val observabilityPlugin = Observability( application = this@MyApplication, mobileKey = mobileKey, options = ObservabilityOptions( serviceName = "my-android-app", serviceVersion = "1.0.0", debug = true, sessionBackgroundTimeout = 30.minutes, logAdapter = LDAndroidLogging.adapter(), resourceAttributes = Attributes.of( AttributeKey.stringKey("environment"), "production", AttributeKey.stringKey("team"), "mobile" ), customHeaders = mapOf( "X-Custom-Header" to "custom-value" ) ) ) ```
Java From Java, build `ObservabilityOptions` with `ObservabilityOptions.builder()` instead of the Kotlin constructor (Java cannot omit Kotlin default parameters). Each setter defaults to the same value as the constructor, so set only what you need. Options typed as a Kotlin `Duration` — such as `sessionBackgroundTimeout` — have a Java-friendly millis overload (`sessionBackgroundTimeoutMillis(long)`). ```java import com.launchdarkly.observability.api.ObservabilityOptions; import com.launchdarkly.observability.plugin.Observability; import com.launchdarkly.sdk.android.LDAndroidLogging; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; import java.util.Collections; String mobileKey = "your-mobile-key"; Observability observabilityPlugin = new Observability( this, mobileKey, ObservabilityOptions.builder() .serviceName("my-android-app") .serviceVersion("1.0.0") .debug(true) // sessionBackgroundTimeout is a Kotlin Duration; use the millis overload from Java. .sessionBackgroundTimeoutMillis(java.util.concurrent.TimeUnit.MINUTES.toMillis(30)) .logAdapter(LDAndroidLogging.adapter()) .resourceAttributes(Attributes.of( AttributeKey.stringKey("environment"), "production", AttributeKey.stringKey("team"), "mobile" )) .customHeaders(Collections.singletonMap("X-Custom-Header", "custom-value")) .build() ); ```
Additional `ObservabilityOptions` settings: - `sessionBackgroundTimeout`: How long the app can stay backgrounded before the current session ends (defaults to 15 minutes). In Kotlin this is a `kotlin.time.Duration` (e.g. `30.minutes`). From Java, where `Duration` is awkward to construct, the builder exposes `sessionBackgroundTimeoutMillis(long)` instead. - `logsApiLevel`: Minimum log severity to export (defaults to `INFO`). Set to `ObservabilityOptions.LogLevel.NONE` to disable log exporting. - `tracesApi`: Controls trace recording (defaults to enabled). Use `ObservabilityOptions.TracesApi.disabled()` to disable all tracing, or set `includeErrors`/`includeSpans`. - `metricsApi`: Controls metric export (defaults to enabled). Use `ObservabilityOptions.MetricsApi.disabled()` to disable metrics. - `instrumentations`: Enables/disables specific automatic instrumentations: - `crashReporting` (default `true`): report uncaught exceptions as errors. - `launchTime` (default `false`): record application startup time as metrics. - `userTaps` (default `true`): run the tap-detection machinery. Disabling `userTaps` stops tap detection entirely, so no `click` spans are emitted regardless of `analytics.taps`. - `screens` (default `true`): automatically detect screen changes from Android `Activity` lifecycle callbacks. This drives the `screen_view` span (gated separately by `analytics.screenViews`) and Session Replay `Navigate` events. - `analytics`: Enables/disables analytics telemetry, emitted as OpenTelemetry spans: - `taps` (default `true`): publish a `click` span for each detected tap. Tap detection is governed by `instrumentations.userTaps`; this flag only controls publishing the span. - `trackEvents` (default `true`): emit a `track` span when a custom event is tracked, either automatically via the LaunchDarkly `afterTrack` hook (`LDClient.track(...)`) or manually via `LDObserve.track(...)`. - `screenViews` (default `true`): emit a `screen_view` span when a screen is shown. This only gates the span; screen *detection* (and Session Replay `Navigate` events) is controlled by `instrumentations.screens`. - `appLifecycle` (default `true`): emit app-lifecycle spans as the app moves between states: `app_foreground` (with `event.lifecycle_state = foreground`) when it enters the foreground, and `app_background` (with `event.lifecycle_state = background`) when it enters the background. This flag only gates the span; the matching Session Replay `Foreground` / `Background` breadcrumbs are emitted regardless. Example: ```kotlin val options = ObservabilityOptions( logsApiLevel = ObservabilityOptions.LogLevel.WARN, tracesApi = ObservabilityOptions.TracesApi(includeErrors = true, includeSpans = false), metricsApi = ObservabilityOptions.MetricsApi.disabled(), instrumentations = ObservabilityOptions.Instrumentations( crashReporting = false, launchTime = true, screens = true ), analytics = ObservabilityOptions.Analytics( taps = true, trackEvents = true, screenViews = true, appLifecycle = true ) ) ```
Java The nested option types (`TracesApi`, `Analytics`, `Instrumentations`) each expose a `builder()` as well; `MetricsApi` keeps its `enabled()` / `disabled()` factories. ```java import com.launchdarkly.observability.api.ObservabilityOptions; ObservabilityOptions options = ObservabilityOptions.builder() .logsApiLevel(ObservabilityOptions.LogLevel.WARN) .tracesApi(ObservabilityOptions.TracesApi.builder() .includeErrors(true) .includeSpans(false) .build()) .metricsApi(ObservabilityOptions.MetricsApi.disabled()) .instrumentations(ObservabilityOptions.Instrumentations.builder() .crashReporting(false) .launchTime(true) .screens(true) .build()) .analytics(ObservabilityOptions.Analytics.builder() .taps(true) .trackEvents(true) .screenViews(true) .build()) .build(); ```
### Tracking Screen Views The SDK emits a `screen_view` span (following the analytics taxonomy `event.*` namespace) whenever a screen is shown. Each `screen_view` also resolves `event.previous_screen` from a shared navigation stack and broadcasts a Session Replay `Navigate` event. #### Automatic capture (Activities) When `instrumentations.screens` is enabled (the default), every Android `Activity` is captured on resume. By default the screen name is derived by cleaning the class name (`ProfileActivity` → `Profile`), and `event.screen_class` / `event.screen_id` are populated from the activity class. To customize the reported name or category, implement `LDScreenNameProvider` on your `Activity`: ```kotlin import com.launchdarkly.observability.client.screen.LDScreenNameProvider class CheckoutActivity : ComponentActivity(), LDScreenNameProvider { override val ldScreenName: String = "Checkout" override val ldScreenCategory: String = "Commerce" } ``` #### Manual capture (Fragments / Compose) Screens that aren't backed by a distinct `Activity` (Fragments, Jetpack Compose destinations) won't be captured automatically. Report them manually with `LDObserve.trackScreenView`: ```kotlin import com.launchdarkly.observability.sdk.LDObserve LDObserve.trackScreenView( name = "Profile", screenClass = "ProfileFragment", screenId = "com.example.app.ProfileFragment", category = "Account" ) ``` A single call per appearance is sufficient regardless of capture mode — `event.previous_screen` is resolved through the shared stack, which handles re-appearance and back-navigation. #### Decoupling detection from the span - `instrumentations.screens` controls automatic screen *detection*. It drives both the automatic `screen_view` span and the Session Replay `Navigate` event. - `analytics.screenViews` only gates the `screen_view` span. Detection and the `Navigate` broadcast are independent of this flag, and manual `trackScreenView(...)` still works when `screens` is disabled. ### Recording Observability Data After initialization of the LaunchDarkly Android Client SDK, use `LDObserve` to record metrics, logs, errors, and traces: ```kotlin import com.launchdarkly.observability.sdk.LDObserve import com.launchdarkly.observability.interfaces.Metric import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Severity // Record metrics LDObserve.recordMetric(Metric("user_actions", 1.0)) LDObserve.recordCount(Metric("api_calls", 1.0)) LDObserve.recordIncr(Metric("page_views", 1.0)) LDObserve.recordHistogram(Metric("response_time", 150.0)) LDObserve.recordUpDownCounter(Metric("active_connections", 1.0)) // Record logs — pass attributes as a plain map via `properties`. LDObserve.recordLog( "User performed action", Severity.INFO, properties = mapOf( "user_id" to "12345", "action" to "button_click" ) ) // Record errors LDObserve.recordError( Exception("Something went wrong"), Attributes.of( AttributeKey.stringKey("component"), "payment", AttributeKey.stringKey("error_code"), "PAYMENT_FAILED" ) ) // Create spans for tracing val span = LDObserve.startSpan( "api_request", properties = mapOf( "endpoint" to "/api/users", "method" to "GET" ) ) span.makeCurrent().use { // Your code here } span.end() // Record a custom track event as a `track` span. // (Calling LDClient.track(...) records the same span automatically via the afterTrack hook.) LDObserve.track( "checkout_completed", properties = mapOf("currency" to "USD"), metricValue = 42.0 ) // Record a `screen_view` span for screens not backed by a distinct Activity // (e.g. Fragments or Compose destinations). Activities are captured automatically. LDObserve.trackScreenView( name = "Profile", screenClass = "ProfileFragment", category = "Account", properties = mapOf("tab" to "overview") ) ``` `recordLog`, `startSpan`, `track`, and `trackScreenView` accept attributes/data as a plain Kotlin map via the `properties` parameter — pass `String`, `Boolean`, `Int`, `Long`, `Double`, lists, and nested maps directly, with no need to build OpenTelemetry `Attributes`. The `Attributes` overloads remain available when you need precise OpenTelemetry typing (as shown for `recordError` above). For `trackScreenView`, custom properties are applied at lower precedence than the reserved `event.*` taxonomy fields, so they can never clobber them. ### Session Replay #### Enable Session Replay Add the Session Replay plugin **after** Observability when configuring the LaunchDarkly SDK: ```kotlin import com.launchdarkly.observability.plugin.Observability import com.launchdarkly.observability.replay.plugin.SessionReplay val ldConfig = LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Enabled) .mobileKey("your-mobile-key") .plugins( Components.plugins().setPlugins( listOf( Observability(this@MyApplication, "your-mobile-key"), SessionReplay() // depends on Observability being present first ) ) ) .build() ``` Notes: - SessionReplay depends on Observability. If Observability is missing or listed after SessionReplay, the plugin logs an error and stays inactive. - Observability runs fine without SessionReplay; adding SessionReplay extends the Observability pipeline to include session recording. #### Delay Start If you need to begin recording after login or later in the app lifecycle, disable automatic capture and start it later: ```kotlin import com.launchdarkly.observability.replay.ReplayOptions import com.launchdarkly.observability.replay.plugin.SessionReplay import com.launchdarkly.observability.sdk.LDReplay val sessionReplay = SessionReplay( ReplayOptions(enabled = false) ) // After login: LDReplay.start() ``` Call `LDReplay.stop()` to pause recording. #### Masking sensitive UI Use `ldMask()` to mark views that should be masked in session replay. There are helpers for both XML-based Views and Jetpack Compose. ##### How the SDK Determines What to Mask When deciding whether a specific view should be masked in a Session Replay, the SDK evaluates rules in a strict order of precedence. It checks these conditions from top to bottom and stops at the first one that applies: 1. **Explicit Masking (Highest Priority)**: Is the view, or *any* of its parent views, explicitly masked (e.g., using `.ldMask()` or matching `maskXMLViewIds`)? * **Yes**: The view is **masked**. This overrides all other rules. 2. **Explicit Unmasking**: Is the view, or *any* of its parent views, explicitly unmasked (e.g., using `.ldUnmask()` or matching `unmaskXMLViewIds`)? * **Yes**: The view is **unmasked**. 3. **Global Configuration**: Does your global privacy configuration (like `maskTextInputs`, `maskImages`, etc.) apply to this view? * **Yes**: The view follows the global configuration. *Note: If multiple rules conflict at the same level, masking wins over unmasking.* ##### Configure masking via `PrivacyProfile` If you want to configure masking globally (instead of calling `ldMask()` on each element), pass a `PrivacyProfile` to `ReplayOptions`: ```kotlin import com.launchdarkly.observability.replay.PrivacyProfile import com.launchdarkly.observability.replay.ReplayOptions import com.launchdarkly.observability.replay.view import com.launchdarkly.observability.replay.plugin.SessionReplay val sessionReplay = SessionReplay( ReplayOptions( privacyProfile = PrivacyProfile( // Toggle built-in masking: // - maskTextInputs: masks text input fields (e.g. EditText / Compose text fields) maskTextInputs = true, // - maskText: masks non-input text targets maskText = false, // New settings: maskViews = listOf( // Masks targets by *exact* Android View class (does not match subclasses). view(android.widget.ImageView::class), // You can also provide the class name as a string (FQCN). view("android.widget.EditText"), ), maskWebViews = true, maskXMLViewIds = listOf( // Masks by resource entry name (from resources.getResourceEntryName(view.id)). // Accepts "@+id/foo", "@id/foo", or "foo". "@+id/password", "credit_card_number", ), unmaskXMLViewIds = listOf( // Unmasks views matching these ids. Same id format as maskXMLViewIds. Takes // precedence over global rules like `maskText`/`maskTextInputs`, but an explicit // mask on the same view or any of its ancestors still wins. "@+id/greeting", ), ) ) ) ``` Notes: - `maskViews` matches on `target.view.javaClass` equality (exact class only). - `maskWebViews` uses a default list of WebView class names for masking (and still allows subclasses), including AndroidView-hosted views inside Compose. - `maskXMLViewIds` and `unmaskXMLViewIds` apply to Views with a non-`View.NO_ID` id that resolves to a resource entry name. When the React Native library is on the runtime classpath, they also match the value of the `react_test_id` tag — i.e. the JS `testID` prop on RN-rendered views. ##### XML Views Import the masking API and call `ldMask()` on any `View` (for example, after inflating the layout in an `Activity` or `Fragment`). ```kotlin import com.launchdarkly.observability.api.ldMask class LoginActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_login) val password = findViewById(R.id.password) password.ldMask() // mask this field in session replay } } ``` With View Binding or Data Binding: ```kotlin import com.launchdarkly.observability.api.ldMask override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { _binding = SettingsPageBinding.inflate(inflater, container, false) binding.nestedScrollView.systemBarsPadding() viewModel.toggleBackgroundAccess(requireContext().isIgnoreBatteryEnabled()) val toolbar = binding.toolbar toolbar.ldMask() } ``` Use `ldUnmask()` to explicitly opt a view out of masking. This overrides global masking rules (e.g. `maskText`) for the view and its descendants — but an explicit `ldMask()` on the view itself or any ancestor still wins. ##### Jetpack Compose Add the masking `Modifier` to any composable you want masked in session replay. ```kotlin import com.launchdarkly.observability.api.ldMask @Composable fun CreditCardField() { ... var zipCode by remember { mutableStateOf("") } OutlinedTextField( value = zipCode, onValueChange = { zipCode = it }, label = { Text("ZIP Code") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier .fillMaxWidth() .ldMask() ) } ``` Use `Modifier.ldUnmask()` to explicitly opt a composable out of masking. This overrides global masking rules (e.g. `maskText`) for the composable and its descendants — but an explicit `ldMask()` on the composable itself or any ancestor still wins. Notes: - Masking marks elements so their contents are obscured in recorded sessions. - You can apply masking to any `View` or composable where sensitive data may appear. ## Contributing We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](../../CONTRIBUTING.md) for instructions on how to contribute to this SDK. ## About LaunchDarkly * LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. * LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Read [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. * Explore LaunchDarkly * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation * [launchdarkly.com/blog](https://launchdarkly.com/blog/ "LaunchDarkly Blog Documentation") for the latest product updates