diff --git a/e2e/android/app/src/main/AndroidManifest.xml b/e2e/android/app/src/main/AndroidManifest.xml index 3213442e3..6dc4460c9 100644 --- a/e2e/android/app/src/main/AndroidManifest.xml +++ b/e2e/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + + + 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 7be3d52b2..53c68820b 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 @@ -125,6 +125,20 @@ class MainActivity : ComponentActivity() { ) { Text("Trigger Log") } + Button( + onClick = { + viewModel.startForegroundService() + } + ) { + Text("Start Foreground Service") + } + Button( + onClick = { + viewModel.startBackgroundService() + } + ) { + Text("Start Background Service") + } Button( onClick = { viewModel.triggerNestedSpans() diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/ObservabilityBackgroundService.kt b/e2e/android/app/src/main/java/com/example/androidobservability/ObservabilityBackgroundService.kt new file mode 100644 index 000000000..63df1c713 --- /dev/null +++ b/e2e/android/app/src/main/java/com/example/androidobservability/ObservabilityBackgroundService.kt @@ -0,0 +1,33 @@ +package com.example.androidobservability + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +class ObservabilityBackgroundService : Service() { + + private val serviceScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var loggingJob: Job? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + loggingJob?.cancel() + loggingJob = serviceScope.launchObservabilityLoggingTask(serviceType = "background") { + stopSelf(startId) + } + + return START_NOT_STICKY + } + + override fun onDestroy() { + loggingJob?.cancel() + serviceScope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/ObservabilityForegroundService.kt b/e2e/android/app/src/main/java/com/example/androidobservability/ObservabilityForegroundService.kt new file mode 100644 index 000000000..61f3beba4 --- /dev/null +++ b/e2e/android/app/src/main/java/com/example/androidobservability/ObservabilityForegroundService.kt @@ -0,0 +1,74 @@ +package com.example.androidobservability + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +private const val FOREGROUND_NOTIFICATION_ID = 1001 +private const val FOREGROUND_CHANNEL_ID = "observability_foreground" + +class ObservabilityForegroundService : Service() { + + private val serviceScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var loggingJob: Job? = null + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForeground(FOREGROUND_NOTIFICATION_ID, buildNotification()) + + loggingJob?.cancel() + loggingJob = serviceScope.launchObservabilityLoggingTask(serviceType = "foreground") { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf(startId) + } + + return START_NOT_STICKY + } + + override fun onDestroy() { + loggingJob?.cancel() + serviceScope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + FOREGROUND_CHANNEL_ID, + "Observability Foreground Service", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Displays status while the observability foreground service is running" + } + + val manager = getSystemService(NotificationManager::class.java) + manager?.createNotificationChannel(channel) + } + } + + private fun buildNotification(): Notification { + return NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("Foreground logging in progress") + .setContentText("Sending observability logs every 5 seconds for 30 seconds.") + .setOngoing(true) + .setSilent(true) + .build() + } +} diff --git a/e2e/android/app/src/main/java/com/example/androidobservability/ObservabilityServiceTasks.kt b/e2e/android/app/src/main/java/com/example/androidobservability/ObservabilityServiceTasks.kt new file mode 100644 index 000000000..b4999341c --- /dev/null +++ b/e2e/android/app/src/main/java/com/example/androidobservability/ObservabilityServiceTasks.kt @@ -0,0 +1,69 @@ +package com.example.androidobservability + +import android.util.Log +import com.launchdarkly.observability.sdk.LDObserve +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.logs.Severity +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val TOTAL_DURATION_MS = 30_000L +private const val TICK_INTERVAL_MS = 5_000L +private const val TOTAL_TICKS = (TOTAL_DURATION_MS / TICK_INTERVAL_MS).toInt() + +/** + * Launches a 30-second logging routine. + */ +fun CoroutineScope.launchObservabilityLoggingTask( + serviceType: String, + onComplete: () -> Unit +): Job { + val serviceAttribute = AttributeKey.stringKey("service_type") + + return launch { + try { + Log.d("observability-services", "Starting $serviceType service logging task") + LDObserve.recordLog( + message = "Starting $serviceType service logging task", + severity = Severity.INFO, + attributes = Attributes.of(serviceAttribute, serviceType) + ) + + repeat(TOTAL_TICKS) { index -> + delay(TICK_INTERVAL_MS) + val attributes = Attributes.builder() + .put(serviceAttribute, serviceType) + .put(AttributeKey.longKey("tick_index"), (index + 1).toLong()) + .put(AttributeKey.longKey("elapsed_ms"), ((index + 1) * TICK_INTERVAL_MS)) + .build() + + Log.d("observability-services", "[$serviceType] Heartbeat ${index + 1} of $TOTAL_TICKS") + LDObserve.recordLog( + message = "[$serviceType] Heartbeat ${index + 1} of $TOTAL_TICKS", + severity = Severity.INFO, + attributes = attributes + ) + } + + Log.d("observability-services", "$serviceType service logging task completed") + LDObserve.recordLog( + message = "$serviceType service logging task completed", + severity = Severity.INFO, + attributes = Attributes.of(serviceAttribute, serviceType) + ) + + onComplete() + } catch (_: CancellationException) { + Log.d("observability-services", "$serviceType service logging task cancelled") + LDObserve.recordLog( + message = "$serviceType service logging task cancelled", + severity = Severity.INFO, + attributes = Attributes.of(serviceAttribute, serviceType) + ) + } + } +} 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 a48def5d1..089b7f492 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,6 +1,9 @@ package com.example.androidobservability -import androidx.lifecycle.ViewModel +import android.app.Application +import android.content.Intent +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.launchdarkly.observability.interfaces.Metric import com.launchdarkly.observability.sdk.LDObserve @@ -18,7 +21,7 @@ import java.io.BufferedInputStream import java.net.HttpURLConnection import java.net.URL -class ViewModel : ViewModel() { +class ViewModel(application: Application) : AndroidViewModel(application) { fun triggerMetric() { LDObserve.recordMetric(Metric("test-gauge", 50.0)) @@ -121,6 +124,16 @@ class ViewModel : ViewModel() { LDClient.get().identify(context) } + fun startForegroundService() { + val intent = Intent(getApplication(), ObservabilityForegroundService::class.java) + ContextCompat.startForegroundService(getApplication(), intent) + } + + fun startBackgroundService() { + val intent = Intent(getApplication(), ObservabilityBackgroundService::class.java) + getApplication().startService(intent) + } + private fun sendOkHttpRequest() { // Create HTTP client val client = OkHttpClient()