Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
87f755e
Launch without reference
abelonogov-ld Apr 14, 2026
4027c97
fix unit tests
abelonogov-ld Apr 14, 2026
a5400f6
minus one
abelonogov-ld Apr 15, 2026
7275c5b
sdk dependencies
abelonogov-ld Apr 15, 2026
0de049f
conditional inclusion
abelonogov-ld Apr 15, 2026
f93c4ac
maui condition
abelonogov-ld Apr 15, 2026
1234372
maui exclusion sdk
abelonogov-ld Apr 15, 2026
29b8018
fix attributes
abelonogov-ld Apr 15, 2026
11f3ea6
fix unit test
abelonogov-ld Apr 15, 2026
a90ed67
fix
abelonogov-ld Apr 15, 2026
9452f28
more fixes
abelonogov-ld Apr 15, 2026
0761bc6
rename package
abelonogov-ld Apr 15, 2026
c55df39
Update fullyQualifiedKey to use fullyQualifiedKey from contexts for i…
abelonogov-ld Apr 16, 2026
13920ef
address feadback
abelonogov-ld Apr 16, 2026
7cea36a
matching to java
abelonogov-ld Apr 16, 2026
dae4510
remove unused
abelonogov-ld Apr 16, 2026
216c872
update
abelonogov-ld Apr 16, 2026
3e1dcc2
Refactor SessionReplay registration logic to avoid exceptions when ob…
abelonogov-ld Apr 16, 2026
76b7bc3
Merge branch 'main' into andrey/ld-android2
abelonogov-ld Apr 17, 2026
8d7564c
clean comment out code
abelonogov-ld Apr 17, 2026
032938f
silence otlp connection exceptions in e2e tests
abelonogov-ld Apr 17, 2026
02a898b
Merge branch 'andrey/silent-otlp-exceptions-in-e2e-tests' into andrey…
abelonogov-ld Apr 17, 2026
ed2e570
fix unit tests
abelonogov-ld Apr 17, 2026
558ed12
Merge branch 'main' into andrey/ld-android2
abelonogov-ld Apr 17, 2026
898e1b7
address feedback
abelonogov-ld Apr 17, 2026
c6d6b1b
Merge branch 'main' into andrey/ld-android2
abelonogov-ld Apr 17, 2026
6ad6a88
fix
abelonogov-ld Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@ import com.launchdarkly.observability.replay.PrivacyProfile
import com.launchdarkly.observability.replay.ReplayOptions
import com.launchdarkly.observability.replay.plugin.SessionReplay
import com.launchdarkly.observability.replay.view
import com.launchdarkly.observability.context.LDObserveContext
import com.launchdarkly.observability.sdk.LDObserve
import com.launchdarkly.observability.sdk.LDReplay
import com.launchdarkly.sdk.ContextKind
import com.launchdarkly.sdk.LDContext
import com.launchdarkly.sdk.android.Components
import com.launchdarkly.sdk.android.LDAndroidLogging
import com.launchdarkly.sdk.android.FeatureFlagChangeListener
import com.launchdarkly.sdk.android.LDClient
import com.launchdarkly.sdk.android.LDConfig
import io.opentelemetry.api.common.AttributeKey
import io.opentelemetry.api.common.Attributes
import com.launchdarkly.observability.sdk.LDReplay
import com.launchdarkly.sdk.android.FeatureFlagChangeListener

open class BaseApplication : Application() {

Expand All @@ -38,32 +39,32 @@ open class BaseApplication : Application() {
instrumentations = ObservabilityOptions.Instrumentations(
crashReporting = true, launchTime = true, activityLifecycle = true
),
logAdapter = LDAndroidLogging.adapter(),
)

val sessionReplayPlugin = SessionReplay(
options = ReplayOptions(
enabled = false,
privacyProfile = PrivacyProfile(
maskText = false,
maskWebViews = true,
maskViews = listOf(
view(ImageView::class.java),
),
maskXMLViewIds = listOf("smoothieTitle")
)
)
)

var testUrl: String? = null

// example on creating OBS/SR with flagging sdk
open fun realInit() {
val observabilityPlugin = Observability(
application = this@BaseApplication,
mobileKey = LAUNCHDARKLY_MOBILE_KEY,
options = testUrl?.let { observabilityOptions.copy(backendUrl = it, otlpEndpoint = it) } ?: observabilityOptions
)

val sessionReplayPlugin = SessionReplay(
options = ReplayOptions(
enabled = false,
privacyProfile = PrivacyProfile(
maskText = false,
maskWebViews = true,
maskViews = listOf(
view(ImageView::class.java),
),
maskXMLViewIds = listOf("smoothieTitle")
)
)
)

// Set LAUNCHDARKLY_MOBILE_KEY to your LaunchDarkly mobile key found on the LaunchDarkly
// dashboard in the start guide.
// If you want to disable the Auto EnvironmentAttributes functionality.
Expand Down Expand Up @@ -96,6 +97,37 @@ open class BaseApplication : Application() {
LDReplay.start()
}

// example on creating OBS/SR without flagging
open fun realIndependentInit() {
Comment thread
abelonogov-ld marked this conversation as resolved.
val effectiveOptions = testUrl?.let {
observabilityOptions.copy(backendUrl = it, otlpEndpoint = it)
} ?: observabilityOptions

val context = LDObserveContext.builder(LDObserveContext.DEFAULT_KIND, "example-user-key")
.anonymous(true)
.build()

LDObserve.init(
application = this@BaseApplication,
mobileKey = LAUNCHDARKLY_MOBILE_KEY,
ldContext = context,
options = effectiveOptions,
replayOptions = ReplayOptions(
enabled = false,
privacyProfile = PrivacyProfile(
maskText = false,
maskWebViews = true,
maskViews = listOf(
view(ImageView::class.java),
),
maskXMLViewIds = listOf("smoothieTitle")
)
)
)

LDReplay.start()
}

fun flagEvaluation() {
val flagKey = "feature1"
val value = LDClient.get().boolVariation(flagKey, false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertTrue
import org.junit.After
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
Expand All @@ -27,6 +28,11 @@ class DisablingConfigOptionsE2ETest {

private val application = ApplicationProvider.getApplicationContext<Application>() as TestApplication

@After
fun tearDown() {
application.tearDownTest()
}

@Test
fun `Logs should NOT be exported when logsApiLevel is NONE`() {
application.observabilityOptions = getOptionsAllEnabled().copy(logsApiLevel = ObservabilityOptions.LogLevel.NONE)
Expand All @@ -46,15 +52,16 @@ class DisablingConfigOptionsE2ETest {
fun `Logs should NOT be exported when log severity is lower than logsApiLevel`() {
application.observabilityOptions = getOptionsAllEnabled().copy(logsApiLevel = ObservabilityOptions.LogLevel.INFO)
application.initForTest()
val logsUrl = "http://localhost:${application.mockWebServer?.port}/v1/logs"

triggerTestLog(severity = Severity.TRACE)
LDObserve.flush()
// With logsApiLevel = INFO the SDK still emits its own INFO "LD.identify" log during init,
// so we can't assert on "no logs at all" or "no /v1/logs request". The intent is that the
// TRACE test-log specifically is filtered out.
waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.LOGS)
val logsExported = application.telemetryInspector?.logExporter?.finishedLogRecordItems

assertTrue(logsExported?.isEmpty() == true)
assertFalse(requestsContainsUrl(logsUrl))
assertFalse(logsExported.orEmpty().any { it.bodyValue?.value.toString() == TEST_LOG_MESSAGE })
}

@Test
Expand Down Expand Up @@ -195,15 +202,18 @@ class DisablingConfigOptionsE2ETest {
instrumentations = ObservabilityOptions.Instrumentations(crashReporting = false)
)
application.initForTest()
val logsUrl = "http://localhost:${application.mockWebServer?.port}/v1/logs"

Thread { throw RuntimeException("Exception for testing") }.start()

waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.LOGS)
val logsExported = application.telemetryInspector?.logExporter?.finishedLogRecordItems

assertFalse(requestsContainsUrl(logsUrl))
assertEquals(0, logsExported?.size)
// logsApiLevel is independent of crash reporting: unrelated SDK logs (e.g. LD.identify)
// may still be exported. We only assert that no crash log reached the exporter.
val crashLogs = logsExported.orEmpty().filter {
it.instrumentationScopeInfo.name == CRASH_INSTRUMENTATION_SCOPE
}
assertEquals(0, crashLogs.size)
}

@Test
Expand Down Expand Up @@ -238,7 +248,7 @@ class DisablingConfigOptionsE2ETest {

private fun triggerTestLog(severity: Severity = Severity.INFO) {
LDObserve.recordLog(
message = "test-log",
message = TEST_LOG_MESSAGE,
severity = severity
)
}
Expand Down Expand Up @@ -275,4 +285,9 @@ class DisablingConfigOptionsE2ETest {
)
)
}

companion object {
private const val TEST_LOG_MESSAGE = "test-log"
private const val CRASH_INSTRUMENTATION_SCOPE = "io.opentelemetry.crash"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import io.opentelemetry.api.common.Attributes
import io.opentelemetry.api.logs.Severity
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
Expand Down Expand Up @@ -55,15 +56,24 @@ class SamplingE2ETest {
telemetryInspector = application.telemetryInspector
}

@After
fun tearDown() {
application.tearDownTest()
}

@Test
fun `should avoid exporting logs matching sampling configuration for logs`() = runTest {
triggerLogs()
telemetryInspector?.logExporter?.flush()
// Force the BatchLogRecordProcessor to drain everything to the exporter so the
// assertion doesn't race with the batch scheduler.
LDObserve.flush()
waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.LOGS)

val logsExported = telemetryInspector?.logExporter?.finishedLogRecordItems?.map {
it.bodyValue?.value.toString()
}
val logsExported = telemetryInspector?.logExporter?.finishedLogRecordItems
?.map { it.bodyValue?.value.toString() }
// The SDK emits an unrelated "LD.identify" INFO log during init; filter it out so
// this test only observes the logs triggered by triggerLogs().
?.filter { it != LD_IDENTIFY_LOG_BODY }

// Only first and final logs should be exported
assertEquals(2, logsExported?.size)
Expand All @@ -74,7 +84,7 @@ class SamplingE2ETest {
@Test
fun `should avoid exporting spans matching sampling configuration for spans`() = runTest {
triggerSpans()
telemetryInspector?.spanExporter?.flush()
LDObserve.flush()
waitForTelemetryData(telemetryInspector = application.telemetryInspector, telemetryType = TelemetryType.SPANS)

val spansExported = telemetryInspector?.spanExporter?.finishedSpanItems?.map {
Expand Down Expand Up @@ -219,4 +229,8 @@ class SamplingE2ETest {
)
)
}

companion object {
private const val LD_IDENTIFY_LOG_BODY = "LD.identify"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,33 @@ class TestApplication : BaseApplication() {
super.realInit()
}

override fun onTerminate() {
/**
* Tears down per-test state so tests don't leak into each other.
*
* Robolectric isolates Android framework classes with a per-method sandbox classloader,
* but third-party JVM classes (including `com.launchdarkly.sdk.android.LDClient`) live
* in the shared system classloader. `LDClient.init()` silently returns the existing
* client if already initialized, which means a second test's `Observability` plugin is
* never registered and `LDObserve` keeps pointing at the previous test's
* `ObservabilityService` (with a now-dead mock server port). Closing `LDClient` resets
* its `instances` static so the next `init` fully reinitializes.
*/
fun tearDownTest() {
try {
LDClient.get().close()
} catch (_: Throwable) {
// LDClient may not have been initialized successfully; ignore.
}
mockWebServer?.let {
it.shutdown()
mockWebServer = null
}
LDClient.get().close()
Comment thread
cursor[bot] marked this conversation as resolved.
telemetryInspector = null
SignalFromDiskExporter.resetForTesting()
}

override fun onTerminate() {
tearDownTest()
super.onTerminate()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,6 @@ dependencies {
implementation("androidx.core:core-ktx:1.15.0")
"copyDependencies"("androidx.core:core-ktx:1.15.0")

// Copy dependencies for binding library
// Uncomment line below and replace dependency.name.goes.here with your dependency
implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.11.0")
"copyDependencies"("com.launchdarkly:launchdarkly-android-client-sdk:5.11.0")

// Intentionally use a non-existent version so this dependency MUST be
// satisfied by composite-build substitution (settings.gradle.kts).
// If substitution breaks, the build should fail instead of silently
Expand Down
Loading
Loading