Skip to content

Commit fb9faeb

Browse files
authored
test: O11Y-685 - Manual way to test signals when app runs in background/foreground service (#275)
## Summary Adds a way to test the observability signals are being created even when the application is hidden but running a foreground or background service. ## How did you test this change? No tests needed ## Are there any deployment considerations? No, the code added just changes the Sample App <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds Android foreground/background services that run 30s periodic observability logging, with new UI buttons to start them and manifest updates for permissions and service registration. > > - **Android sample app**: > - **Services**: > - Add `ObservabilityForegroundService` with notification channel and foreground execution. > - Add `ObservabilityBackgroundService` for non-foreground execution. > - Shared task `launchObservabilityLoggingTask` logs every 5s for 30s via `LDObserve`. > - **ViewModel**: > - Convert to `AndroidViewModel` and add `startForegroundService()` and `startBackgroundService()` helpers. > - **UI** (`MainActivity`): > - Add buttons to start foreground/background services. > - **Manifest**: > - Declare `android.permission.FOREGROUND_SERVICE`. > - Register `ObservabilityForegroundService` and `ObservabilityBackgroundService`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 210ab48. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 2e5e96b commit fb9faeb

File tree

6 files changed

+213
-3
lines changed

6 files changed

+213
-3
lines changed

e2e/android/app/src/main/AndroidManifest.xml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
33
xmlns:tools="http://schemas.android.com/tools" >
44

5+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
6+
57
<application
68
android:name=".BaseApplication"
79
android:allowBackup="true"
@@ -19,10 +21,15 @@
1921
android:exported="false"
2022
android:label="@string/title_activity_secondary"
2123
android:theme="@style/Theme.AndroidObservability" />
24+
<service
25+
android:name=".ObservabilityForegroundService"
26+
android:exported="false" />
27+
<service
28+
android:name=".ObservabilityBackgroundService"
29+
android:exported="false" />
2230
<activity
2331
android:name=".MainActivity"
2432
android:exported="true"
25-
android:label="@string/app_name"
2633
android:theme="@style/Theme.AndroidObservability" >
2734
<intent-filter>
2835
<action android:name="android.intent.action.MAIN" />

e2e/android/app/src/main/java/com/example/androidobservability/MainActivity.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,20 @@ class MainActivity : ComponentActivity() {
125125
) {
126126
Text("Trigger Log")
127127
}
128+
Button(
129+
onClick = {
130+
viewModel.startForegroundService()
131+
}
132+
) {
133+
Text("Start Foreground Service")
134+
}
135+
Button(
136+
onClick = {
137+
viewModel.startBackgroundService()
138+
}
139+
) {
140+
Text("Start Background Service")
141+
}
128142
Button(
129143
onClick = {
130144
viewModel.triggerNestedSpans()
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.example.androidobservability
2+
3+
import android.app.Service
4+
import android.content.Intent
5+
import android.os.IBinder
6+
import kotlinx.coroutines.CoroutineScope
7+
import kotlinx.coroutines.Dispatchers
8+
import kotlinx.coroutines.Job
9+
import kotlinx.coroutines.SupervisorJob
10+
import kotlinx.coroutines.cancel
11+
12+
class ObservabilityBackgroundService : Service() {
13+
14+
private val serviceScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
15+
private var loggingJob: Job? = null
16+
17+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
18+
loggingJob?.cancel()
19+
loggingJob = serviceScope.launchObservabilityLoggingTask(serviceType = "background") {
20+
stopSelf(startId)
21+
}
22+
23+
return START_NOT_STICKY
24+
}
25+
26+
override fun onDestroy() {
27+
loggingJob?.cancel()
28+
serviceScope.cancel()
29+
super.onDestroy()
30+
}
31+
32+
override fun onBind(intent: Intent?): IBinder? = null
33+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.example.androidobservability
2+
3+
import android.app.Notification
4+
import android.app.NotificationChannel
5+
import android.app.NotificationManager
6+
import android.app.Service
7+
import android.content.Intent
8+
import android.os.Build
9+
import android.os.IBinder
10+
import androidx.core.app.NotificationCompat
11+
import kotlinx.coroutines.CoroutineScope
12+
import kotlinx.coroutines.Dispatchers
13+
import kotlinx.coroutines.Job
14+
import kotlinx.coroutines.SupervisorJob
15+
import kotlinx.coroutines.cancel
16+
17+
private const val FOREGROUND_NOTIFICATION_ID = 1001
18+
private const val FOREGROUND_CHANNEL_ID = "observability_foreground"
19+
20+
class ObservabilityForegroundService : Service() {
21+
22+
private val serviceScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
23+
private var loggingJob: Job? = null
24+
25+
override fun onCreate() {
26+
super.onCreate()
27+
createNotificationChannel()
28+
}
29+
30+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
31+
startForeground(FOREGROUND_NOTIFICATION_ID, buildNotification())
32+
33+
loggingJob?.cancel()
34+
loggingJob = serviceScope.launchObservabilityLoggingTask(serviceType = "foreground") {
35+
stopForeground(STOP_FOREGROUND_REMOVE)
36+
stopSelf(startId)
37+
}
38+
39+
return START_NOT_STICKY
40+
}
41+
42+
override fun onDestroy() {
43+
loggingJob?.cancel()
44+
serviceScope.cancel()
45+
super.onDestroy()
46+
}
47+
48+
override fun onBind(intent: Intent?): IBinder? = null
49+
50+
private fun createNotificationChannel() {
51+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
52+
val channel = NotificationChannel(
53+
FOREGROUND_CHANNEL_ID,
54+
"Observability Foreground Service",
55+
NotificationManager.IMPORTANCE_LOW
56+
).apply {
57+
description = "Displays status while the observability foreground service is running"
58+
}
59+
60+
val manager = getSystemService(NotificationManager::class.java)
61+
manager?.createNotificationChannel(channel)
62+
}
63+
}
64+
65+
private fun buildNotification(): Notification {
66+
return NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID)
67+
.setSmallIcon(R.mipmap.ic_launcher)
68+
.setContentTitle("Foreground logging in progress")
69+
.setContentText("Sending observability logs every 5 seconds for 30 seconds.")
70+
.setOngoing(true)
71+
.setSilent(true)
72+
.build()
73+
}
74+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package com.example.androidobservability
2+
3+
import android.util.Log
4+
import com.launchdarkly.observability.sdk.LDObserve
5+
import io.opentelemetry.api.common.AttributeKey
6+
import io.opentelemetry.api.common.Attributes
7+
import io.opentelemetry.api.logs.Severity
8+
import kotlinx.coroutines.CancellationException
9+
import kotlinx.coroutines.CoroutineScope
10+
import kotlinx.coroutines.Job
11+
import kotlinx.coroutines.delay
12+
import kotlinx.coroutines.launch
13+
14+
private const val TOTAL_DURATION_MS = 30_000L
15+
private const val TICK_INTERVAL_MS = 5_000L
16+
private const val TOTAL_TICKS = (TOTAL_DURATION_MS / TICK_INTERVAL_MS).toInt()
17+
18+
/**
19+
* Launches a 30-second logging routine.
20+
*/
21+
fun CoroutineScope.launchObservabilityLoggingTask(
22+
serviceType: String,
23+
onComplete: () -> Unit
24+
): Job {
25+
val serviceAttribute = AttributeKey.stringKey("service_type")
26+
27+
return launch {
28+
try {
29+
Log.d("observability-services", "Starting $serviceType service logging task")
30+
LDObserve.recordLog(
31+
message = "Starting $serviceType service logging task",
32+
severity = Severity.INFO,
33+
attributes = Attributes.of(serviceAttribute, serviceType)
34+
)
35+
36+
repeat(TOTAL_TICKS) { index ->
37+
delay(TICK_INTERVAL_MS)
38+
val attributes = Attributes.builder()
39+
.put(serviceAttribute, serviceType)
40+
.put(AttributeKey.longKey("tick_index"), (index + 1).toLong())
41+
.put(AttributeKey.longKey("elapsed_ms"), ((index + 1) * TICK_INTERVAL_MS))
42+
.build()
43+
44+
Log.d("observability-services", "[$serviceType] Heartbeat ${index + 1} of $TOTAL_TICKS")
45+
LDObserve.recordLog(
46+
message = "[$serviceType] Heartbeat ${index + 1} of $TOTAL_TICKS",
47+
severity = Severity.INFO,
48+
attributes = attributes
49+
)
50+
}
51+
52+
Log.d("observability-services", "$serviceType service logging task completed")
53+
LDObserve.recordLog(
54+
message = "$serviceType service logging task completed",
55+
severity = Severity.INFO,
56+
attributes = Attributes.of(serviceAttribute, serviceType)
57+
)
58+
59+
onComplete()
60+
} catch (_: CancellationException) {
61+
Log.d("observability-services", "$serviceType service logging task cancelled")
62+
LDObserve.recordLog(
63+
message = "$serviceType service logging task cancelled",
64+
severity = Severity.INFO,
65+
attributes = Attributes.of(serviceAttribute, serviceType)
66+
)
67+
}
68+
}
69+
}

e2e/android/app/src/main/java/com/example/androidobservability/ViewModel.kt

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.example.androidobservability
22

3-
import androidx.lifecycle.ViewModel
3+
import android.app.Application
4+
import android.content.Intent
5+
import androidx.core.content.ContextCompat
6+
import androidx.lifecycle.AndroidViewModel
47
import androidx.lifecycle.viewModelScope
58
import com.launchdarkly.observability.interfaces.Metric
69
import com.launchdarkly.observability.sdk.LDObserve
@@ -18,7 +21,7 @@ import java.io.BufferedInputStream
1821
import java.net.HttpURLConnection
1922
import java.net.URL
2023

21-
class ViewModel : ViewModel() {
24+
class ViewModel(application: Application) : AndroidViewModel(application) {
2225

2326
fun triggerMetric() {
2427
LDObserve.recordMetric(Metric("test-gauge", 50.0))
@@ -121,6 +124,16 @@ class ViewModel : ViewModel() {
121124
LDClient.get().identify(context)
122125
}
123126

127+
fun startForegroundService() {
128+
val intent = Intent(getApplication(), ObservabilityForegroundService::class.java)
129+
ContextCompat.startForegroundService(getApplication(), intent)
130+
}
131+
132+
fun startBackgroundService() {
133+
val intent = Intent(getApplication(), ObservabilityBackgroundService::class.java)
134+
getApplication<Application>().startService(intent)
135+
}
136+
124137
private fun sendOkHttpRequest() {
125138
// Create HTTP client
126139
val client = OkHttpClient()

0 commit comments

Comments
 (0)