From 28da5f25a4ce0772e7280943f944fe41fe8c8389 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Fri, 22 Aug 2025 11:05:55 -0400 Subject: [PATCH 1/6] feat: adds otel-android HTTP instrumentation --- e2e/android/app/build.gradle.kts | 10 +++ .../androidobservability/MainActivity.kt | 18 ++--- .../example/androidobservability/ViewModel.kt | 74 +++++++++++++++---- .../lib/build.gradle.kts | 9 +++ .../client/InstrumentationManager.kt | 1 - 5 files changed, 86 insertions(+), 26 deletions(-) diff --git a/e2e/android/app/build.gradle.kts b/e2e/android/app/build.gradle.kts index d9f89581c..46c13033c 100644 --- a/e2e/android/app/build.gradle.kts +++ b/e2e/android/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + id("net.bytebuddy.byte-buddy-gradle-plugin") version "1.17.6" } android { @@ -52,6 +53,15 @@ dependencies { implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.51.0") implementation("io.opentelemetry:opentelemetry-sdk-metrics:1.51.0") + // 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") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.google.android.material:material:1.12.0") implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/MainActivity.kt b/e2e/android/app/src/main/java/com/example/androidobservability/MainActivity.kt index e84fa32f0..6398b8b66 100644 --- a/e2e/android/app/src/main/java/com/example/androidobservability/MainActivity.kt +++ b/e2e/android/app/src/main/java/com/example/androidobservability/MainActivity.kt @@ -37,6 +37,13 @@ class MainActivity : ComponentActivity() { ) { Text("Go to Secondary Activity") } + Button( + onClick = { + viewModel.triggerHttpRequests() + } + ) { + Text("Trigger HTTP Request") + } Button( onClick = { viewModel.triggerMetric() @@ -60,17 +67,10 @@ class MainActivity : ComponentActivity() { } Button( onClick = { - viewModel.triggerStartSpan() - } - ) { - Text("Trigger Start Span") - } - Button( - onClick = { - viewModel.triggerStopSpan() + viewModel.triggerNestedSpans() } ) { - Text("Trigger Stop Span") + Text("Trigger Nested Spans") } Button( onClick = { diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/ViewModel.kt b/e2e/android/app/src/main/java/com/example/androidobservability/ViewModel.kt index fdee95b32..71b067ba2 100644 --- a/e2e/android/app/src/main/java/com/example/androidobservability/ViewModel.kt +++ b/e2e/android/app/src/main/java/com/example/androidobservability/ViewModel.kt @@ -1,19 +1,22 @@ package com.example.androidobservability import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.sdk.LDObserve -import com.launchdarkly.sdk.android.LDClient import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Severity -import io.opentelemetry.api.trace.Span -import java.net.SocketTimeoutException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.BufferedInputStream +import java.net.HttpURLConnection +import java.net.URL class ViewModel : ViewModel() { - private var lastSpan: Span? = null - fun triggerMetric() { LDObserve.recordMetric(Metric("test", 50.0)) } @@ -33,20 +36,59 @@ class ViewModel : ViewModel() { ) } - fun triggerStartSpan() { - val newSpan = LDObserve.startSpan("FakeSpan", Attributes.empty()) - newSpan.makeCurrent() - lastSpan = newSpan - LDClient.get().boolVariation("my-boolean-flag", false) - } - - fun triggerStopSpan() { - // TODO O11Y-397: for some reason stopped spans are stacking, the current span might be the problem - lastSpan?.end() - lastSpan = null + fun triggerNestedSpans() { + viewModelScope.launch(Dispatchers.IO) { + val newSpan0 = LDObserve.startSpan("FakeSpan", Attributes.empty()) + newSpan0.makeCurrent().use { + val newSpan1 = LDObserve.startSpan("FakeSpan1", Attributes.empty()) + newSpan1.makeCurrent().use { + val newSpan2 = LDObserve.startSpan("FakeSpan2", Attributes.empty()) + newSpan2.makeCurrent().use { + sendOkHttpRequest() + sendURLRequest() + newSpan2.end() + } + newSpan1.end() + } + newSpan0.end() + } + } } fun triggerCrash() { throw RuntimeException("Failed to connect to bogus server.") } + + fun triggerHttpRequests() { + viewModelScope.launch(Dispatchers.IO) { + sendOkHttpRequest() + sendURLRequest() + } + } + + private fun sendOkHttpRequest() { + // Create HTTP client + val client = OkHttpClient() + + // Build request + val request: Request = Request.Builder() + .url("https://www.google.com") + .build() + + client.newCall(request).execute().use { response -> + println("Response code: " + response.code) + println("Response body: " + response.body?.string()) + } + } + + private fun sendURLRequest() { + val url = URL("https://www.android.com/") + val urlConnection = url.openConnection() as HttpURLConnection + try { + val output = BufferedInputStream(urlConnection.inputStream).bufferedReader().use { it.readText() } + println("URLRequest output: $output") + } finally { + urlConnection.disconnect() + } + } } diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 260b06bbf..13e72359d 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("com.android.library") id("maven-publish") id("signing") + id("net.bytebuddy.byte-buddy-gradle-plugin") version "1.17.6" // Apply the Kotlin Android plugin for Android-compatible Kotlin support. alias(libs.plugins.kotlin.android) @@ -38,6 +39,14 @@ dependencies { // Android crash instrumentation implementation("io.opentelemetry.android.instrumentation:crash:0.11.0-alpha") + // 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") + // Use JUnit Jupiter for testing. testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt index 463c86dc0..38c185ed5 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt @@ -189,7 +189,6 @@ class InstrumentationManager( fun startSpan(name: String, attributes: Attributes): Span { return otelTracer.spanBuilder(name) - .setParent(Context.current().with(Span.current())) .setAllAttributes(attributes) .startSpan() } From 3b874367f0181b7dbd77505f3766652f476d0ea4 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 25 Aug 2025 10:22:14 -0400 Subject: [PATCH 2/6] removing unnecessary dependencies --- .../observability-android/lib/build.gradle.kts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 13e72359d..0d909f241 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -39,14 +39,6 @@ dependencies { // Android crash instrumentation implementation("io.opentelemetry.android.instrumentation:crash:0.11.0-alpha") - // 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") - // Use JUnit Jupiter for testing. testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") From 18bcf3dc2ee99bcf704e5592da6f5ca45c05f2d7 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 25 Aug 2025 10:30:26 -0400 Subject: [PATCH 3/6] removing unnecessary dependencies --- sdk/@launchdarkly/observability-android/lib/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 0d909f241..260b06bbf 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -3,7 +3,6 @@ plugins { id("com.android.library") id("maven-publish") id("signing") - id("net.bytebuddy.byte-buddy-gradle-plugin") version "1.17.6" // Apply the Kotlin Android plugin for Android-compatible Kotlin support. alias(libs.plugins.kotlin.android) From 0a26a1fb3beefaf22f4b5a33d19c9a671912d139 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Wed, 27 Aug 2025 10:45:01 -0400 Subject: [PATCH 4/6] feat: adds otel-android click instrumentation --- e2e/android/app/build.gradle.kts | 7 +- .../androidobservability/MainActivity.kt | 25 ++++- e2e/android/gradle/libs.versions.toml | 2 +- .../lib/build.gradle.kts | 18 +++- .../client/InstrumentationManager.kt | 20 +++- .../vendored/otel/SessionIdTimeoutHandler.kt | 81 ++++++++++++++++ .../vendored/otel/SessionManager.kt | 92 +++++++++++++++++++ 7 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionIdTimeoutHandler.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionManager.kt diff --git a/e2e/android/app/build.gradle.kts b/e2e/android/app/build.gradle.kts index 46c13033c..36324aa9b 100644 --- a/e2e/android/app/build.gradle.kts +++ b/e2e/android/app/build.gradle.kts @@ -7,12 +7,11 @@ plugins { android { namespace = "com.example.androidobservability" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "com.example.androidobservability" minSdk = 24 - targetSdk = 35 versionCode = 1 versionName = "1.0" @@ -40,6 +39,10 @@ android { } } +configurations.all { + exclude(group = "com.squareup.okhttp3", module = "okhttp-jvm") +} + dependencies { // Uncomment to use the local project implementation(project(":observability-android")) diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/MainActivity.kt b/e2e/android/app/src/main/java/com/example/androidobservability/MainActivity.kt index 6398b8b66..a4b1c163a 100644 --- a/e2e/android/app/src/main/java/com/example/androidobservability/MainActivity.kt +++ b/e2e/android/app/src/main/java/com/example/androidobservability/MainActivity.kt @@ -14,6 +14,8 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import com.example.androidobservability.ui.theme.AndroidObservabilityTheme @@ -27,10 +29,13 @@ class MainActivity : ComponentActivity() { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Column { Text( - text = "Hello Telemetry", + text = "Hello Android Observability", modifier = Modifier.padding(innerPadding) ) Button( + modifier = Modifier.semantics { + contentDescription = "buttonGoToSecondActivity" + }, onClick = { this@MainActivity.startActivity(Intent(this@MainActivity, SecondaryActivity::class.java)) } @@ -38,6 +43,9 @@ class MainActivity : ComponentActivity() { Text("Go to Secondary Activity") } Button( + modifier = Modifier.semantics { + contentDescription = "buttonTriggerHttp" + }, onClick = { viewModel.triggerHttpRequests() } @@ -45,6 +53,9 @@ class MainActivity : ComponentActivity() { Text("Trigger HTTP Request") } Button( + modifier = Modifier.semantics { + contentDescription = "buttonTriggerMetric" + }, onClick = { viewModel.triggerMetric() } @@ -52,6 +63,9 @@ class MainActivity : ComponentActivity() { Text("Trigger Metric") } Button( + modifier = Modifier.semantics { + contentDescription = "buttonTriggerError" + }, onClick = { viewModel.triggerError() } @@ -59,6 +73,9 @@ class MainActivity : ComponentActivity() { Text("Trigger Error") } Button( + modifier = Modifier.semantics { + contentDescription = "buttonTriggerLog" + }, onClick = { viewModel.triggerLog() } @@ -66,6 +83,9 @@ class MainActivity : ComponentActivity() { Text("Trigger Log") } Button( + modifier = Modifier.semantics { + contentDescription = "buttonTriggerNestedSpans" + }, onClick = { viewModel.triggerNestedSpans() } @@ -73,6 +93,9 @@ class MainActivity : ComponentActivity() { Text("Trigger Nested Spans") } Button( + modifier = Modifier.semantics { + contentDescription = "buttonTriggerCrash" + }, onClick = { viewModel.triggerCrash() } diff --git a/e2e/android/gradle/libs.versions.toml b/e2e/android/gradle/libs.versions.toml index 9d585b114..0595c1878 100644 --- a/e2e/android/gradle/libs.versions.toml +++ b/e2e/android/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "8.9.2" -kotlin = "2.0.21" +kotlin = "2.2.10" coreKtx = "1.16.0" junit = "4.13.2" junitVersion = "1.2.1" diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 260b06bbf..31cbc3c0d 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -15,6 +15,10 @@ allprojects { } } +configurations.all { + exclude(group = "com.squareup.okhttp3", module = "okhttp-jvm") +} + dependencies { implementation("com.launchdarkly:launchdarkly-android-client-sdk:5.9.0") implementation("com.jakewharton.timber:timber:5.0.1") @@ -31,12 +35,16 @@ dependencies { implementation("io.opentelemetry:opentelemetry-api-incubator:1.51.0-alpha") // Android instrumentation - implementation("io.opentelemetry.android:core:0.11.0-alpha") - implementation("io.opentelemetry.android.instrumentation:activity:0.11.0-alpha") - implementation("io.opentelemetry.android:session:0.11.0-alpha") + implementation("io.opentelemetry.android:core:0.14.0-alpha") + implementation("io.opentelemetry.android.instrumentation:activity:0.14.0-alpha") + implementation("io.opentelemetry.android:session:0.14.0-alpha") + implementation("io.opentelemetry.android:android-agent:0.14.0-alpha") // Android crash instrumentation - implementation("io.opentelemetry.android.instrumentation:crash:0.11.0-alpha") + implementation("io.opentelemetry.android.instrumentation:crash:0.14.0-alpha") + + // Android click instrumentation for Compose + implementation("io.opentelemetry.android.instrumentation:compose-click:0.14.0-alpha") // Use JUnit Jupiter for testing. testImplementation("org.junit.jupiter:junit-jupiter") @@ -47,7 +55,7 @@ val releaseVersion = version.toString() android { namespace = "com.launchdarkly.observability" - compileSdk = 30 + compileSdk = 36 buildFeatures { buildConfig = true diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt index 38c185ed5..96db1f699 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt @@ -4,9 +4,13 @@ import android.app.Application import com.launchdarkly.logging.LDLogger import com.launchdarkly.observability.api.Options import com.launchdarkly.observability.interfaces.Metric +import com.launchdarkly.observability.vendored.otel.SessionIdTimeoutHandler +import com.launchdarkly.observability.vendored.otel.SessionManager import io.opentelemetry.android.OpenTelemetryRum +import io.opentelemetry.android.agent.session.SessionConfig import io.opentelemetry.android.config.OtelRumConfig -import io.opentelemetry.android.session.SessionConfig +import io.opentelemetry.android.internal.services.Services +import io.opentelemetry.android.session.SessionProvider import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Logger import io.opentelemetry.api.logs.Severity @@ -47,10 +51,9 @@ class InstrumentationManager( private var otelTracer: Tracer init { - val otelRumConfig = OtelRumConfig().setSessionConfig( - SessionConfig(backgroundInactivityTimeout = options.sessionBackgroundTimeout) - ) + val otelRumConfig = OtelRumConfig() // default otelRUM = OpenTelemetryRum.builder(application, otelRumConfig) + .setSessionProvider(createSessionProvider(application, SessionConfig(backgroundInactivityTimeout = options.sessionBackgroundTimeout))) .addLoggerProviderCustomizer { sdkLoggerProviderBuilder, application -> val logExporter = OtlpHttpLogRecordExporter.builder() .setEndpoint(options.otlpEndpoint + LOGS_PATH) @@ -192,4 +195,13 @@ class InstrumentationManager( .setAllAttributes(attributes) .startSpan() } + + private fun createSessionProvider( + application: Application, + sessionConfig: SessionConfig, + ): SessionProvider { + val timeoutHandler = SessionIdTimeoutHandler(sessionConfig) + Services.get(application).appLifecycle.registerListener(timeoutHandler) + return SessionManager.create(timeoutHandler, sessionConfig) + } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionIdTimeoutHandler.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionIdTimeoutHandler.kt new file mode 100644 index 000000000..e8bd6cd67 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionIdTimeoutHandler.kt @@ -0,0 +1,81 @@ +/** + * Originally from https://github.com/open-telemetry/opentelemetry-android/blob/main/android-agent/src/main/kotlin/io/opentelemetry/android/agent/session/SessionManager.kt + * + * Was publicly available before 0.14.0-alpha and this implementation meets our needs. We will come back + * to check for any updates before this is released in a 1.X version of our plugin. O11Y-443 tracks this task. + * + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.launchdarkly.observability.vendored.otel + +import io.opentelemetry.android.agent.session.SessionConfig +import io.opentelemetry.android.internal.services.applifecycle.ApplicationStateListener +import io.opentelemetry.sdk.common.Clock +import kotlin.time.Duration + +/** + * This class encapsulates the following criteria about the sessionId timeout: + * + * + * * If the app is in the foreground sessionId should never time out. + * * If the app is in the background and no activity (spans) happens for >15 minutes, sessionId + * should time out. + * * If the app is in the background and some activity (spans) happens in <15 minute intervals, + * sessionId should not time out. + * + * + * Consequently, when the app spent >15 minutes without any activity (spans) in the background, + * after moving to the foreground the first span should trigger the sessionId timeout. + */ +internal class SessionIdTimeoutHandler( + private val clock: Clock, + private val sessionBackgroundInactivityTimeout: Duration, +) : ApplicationStateListener { + @Volatile + private var timeoutStartNanos: Long = 0 + + @Volatile + private var state = State.FOREGROUND + + // for testing + internal constructor(sessionConfig: SessionConfig) : this( + Clock.getDefault(), + sessionConfig.backgroundInactivityTimeout, + ) + + override fun onApplicationForegrounded() { + state = State.TRANSITIONING_TO_FOREGROUND + } + + override fun onApplicationBackgrounded() { + state = State.BACKGROUND + } + + fun hasTimedOut(): Boolean { + // don't apply sessionId timeout to apps in the foreground + if (state == State.FOREGROUND) { + return false + } + val elapsedTime = clock.nanoTime() - timeoutStartNanos + return elapsedTime >= sessionBackgroundInactivityTimeout.inWholeNanoseconds + } + + fun bump() { + timeoutStartNanos = clock.nanoTime() + + // move from the temporary transition state to foreground after the first span + if (state == State.TRANSITIONING_TO_FOREGROUND) { + state = State.FOREGROUND + } + } + + private enum class State { + FOREGROUND, + BACKGROUND, + + /** A temporary state representing the first event after the app has been brought back. */ + TRANSITIONING_TO_FOREGROUND, + } +} \ No newline at end of file diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionManager.kt new file mode 100644 index 000000000..60de5b75c --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionManager.kt @@ -0,0 +1,92 @@ +/** + * Originally from https://github.com/open-telemetry/opentelemetry-android/blob/main/android-agent/src/main/kotlin/io/opentelemetry/android/agent/session/SessionManager.kt + * + * Was publicly available before 0.14.0-alpha and this implementation meets our needs. There are a couple thread safety concerns in this code, + * but we expect those to be addressed by the otel-android maintainers. Rather than fix them ourselves and deviate/fork, we will come back + * to check for any updates before this is released in a 1.X version of our plugin. O11Y-443 tracks this task. + * + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.launchdarkly.observability.vendored.otel + +import io.opentelemetry.android.agent.session.SessionConfig +import io.opentelemetry.android.agent.session.SessionIdGenerator +import io.opentelemetry.android.agent.session.SessionStorage +import io.opentelemetry.android.session.Session +import io.opentelemetry.android.session.SessionObserver +import io.opentelemetry.android.session.SessionProvider +import io.opentelemetry.android.session.SessionPublisher +import io.opentelemetry.sdk.common.Clock +import java.util.Collections.synchronizedList +import kotlin.time.Duration + +internal class SessionManager( + private val clock: Clock = Clock.getDefault(), + private val sessionStorage: SessionStorage = SessionStorage.InMemory(), + private val timeoutHandler: SessionIdTimeoutHandler, + private val idGenerator: SessionIdGenerator = SessionIdGenerator.DEFAULT, + private val maxSessionLifetime: Duration, +) : SessionProvider, + SessionPublisher { + // TODO: Make thread safe / wrap with AtomicReference? + private var session: Session = Session.NONE + private val observers = synchronizedList(ArrayList()) + + init { + sessionStorage.save(session) + } + + override fun addObserver(observer: SessionObserver) { + observers.add(observer) + } + + override fun getSessionId(): String { + // value will never be null + var newSession = session + + if (sessionHasExpired() || timeoutHandler.hasTimedOut()) { + val newId = idGenerator.generateSessionId() + + // TODO FIXME: This is not threadsafe -- if two threads call getSessionId() + // at the same time while timed out, two new sessions are created + // Could require SessionStorage impls to be atomic/threadsafe or + // do the locking in this class? + + newSession = Session.DefaultSession(newId, clock.now()) + sessionStorage.save(newSession) + } + + timeoutHandler.bump() + + // observers need to be called after bumping the timer because it may + // create a new span + if (newSession != session) { + val previousSession = session + session = newSession + observers.forEach { + it.onSessionEnded(previousSession) + it.onSessionStarted(session, previousSession) + } + } + return session.getId() + } + + private fun sessionHasExpired(): Boolean { + val elapsedTime = clock.now() - session.getStartTimestamp() + return elapsedTime >= maxSessionLifetime.inWholeNanoseconds + } + + companion object { + @JvmStatic + fun create( + timeoutHandler: SessionIdTimeoutHandler, + sessionConfig: SessionConfig, + ): SessionManager = + SessionManager( + timeoutHandler = timeoutHandler, + maxSessionLifetime = sessionConfig.maxLifetime, + ) + } +} \ No newline at end of file From 445f89736ddd8ff936bc0bb0dd89c93c29005b58 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Wed, 27 Aug 2025 10:55:29 -0400 Subject: [PATCH 5/6] small self review tweaks --- e2e/android/app/build.gradle.kts | 4 ++++ sdk/@launchdarkly/observability-android/lib/build.gradle.kts | 4 ++++ .../observability/vendored/otel/SessionIdTimeoutHandler.kt | 2 +- .../observability/vendored/otel/SessionManager.kt | 2 +- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/e2e/android/app/build.gradle.kts b/e2e/android/app/build.gradle.kts index 36324aa9b..dfd3ffa83 100644 --- a/e2e/android/app/build.gradle.kts +++ b/e2e/android/app/build.gradle.kts @@ -40,6 +40,10 @@ android { } configurations.all { + // Needed to exclude okhttp-jvm dependency from io.opentelemetry:opentelemetry-exporter-otlp that collided with + // okhttp dependency from com.launchdarkly:launchdarkly-android-client-sdk. Next steps would be to update + // com.launchdarkly:launchdarkly-android-client-sdk to use okhttp 5+ as otel libraries are using 5+ at the + // time of writing this. exclude(group = "com.squareup.okhttp3", module = "okhttp-jvm") } diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 31cbc3c0d..16239dd71 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -16,6 +16,10 @@ allprojects { } configurations.all { + // Needed to exclude okhttp-jvm dependency from io.opentelemetry:opentelemetry-exporter-otlp that collided with + // okhttp dependency from com.launchdarkly:launchdarkly-android-client-sdk. Next steps would be to update + // com.launchdarkly:launchdarkly-android-client-sdk to use okhttp 5+ as otel libraries are using 5+ at the + // time of writing this. exclude(group = "com.squareup.okhttp3", module = "okhttp-jvm") } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionIdTimeoutHandler.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionIdTimeoutHandler.kt index e8bd6cd67..1703a41a9 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionIdTimeoutHandler.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionIdTimeoutHandler.kt @@ -78,4 +78,4 @@ internal class SessionIdTimeoutHandler( /** A temporary state representing the first event after the app has been brought back. */ TRANSITIONING_TO_FOREGROUND, } -} \ No newline at end of file +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionManager.kt index 60de5b75c..fbcd2c3d3 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionManager.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/vendored/otel/SessionManager.kt @@ -89,4 +89,4 @@ internal class SessionManager( maxSessionLifetime = sessionConfig.maxLifetime, ) } -} \ No newline at end of file +} From 92ca68a775aefb7bfc9a4da4a93b9c01e722c3f6 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Tue, 2 Sep 2025 15:50:25 -0400 Subject: [PATCH 6/6] removing extra button in e2e test app --- .../java/com/example/androidobservability/MainActivity.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/MainActivity.kt b/e2e/android/app/src/main/java/com/example/androidobservability/MainActivity.kt index d676e23b4..a4b1c163a 100644 --- a/e2e/android/app/src/main/java/com/example/androidobservability/MainActivity.kt +++ b/e2e/android/app/src/main/java/com/example/androidobservability/MainActivity.kt @@ -56,13 +56,6 @@ class MainActivity : ComponentActivity() { modifier = Modifier.semantics { contentDescription = "buttonTriggerMetric" }, - onClick = { - viewModel.triggerHttpRequests() - } - ) { - Text("Trigger HTTP Request") - } - Button( onClick = { viewModel.triggerMetric() }