Skip to content

Commit cfbd45c

Browse files
authored
feat: Application Insights telemetry for backend and Android (#280)
1 parent 8219e94 commit cfbd45c

54 files changed

Lines changed: 1453 additions & 60 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/api-build.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ jobs:
3030
- name: Build
3131
run: |
3232
cd apiserver
33-
go build -v ./...
33+
VERSION=$(date -u +'%y%m%d').00.1
34+
go build -v \
35+
-ldflags "-X dkhalife.com/tasks/core/internal/version.Version=${VERSION} \
36+
-X dkhalife.com/tasks/core/internal/version.BuildNumber=0 \
37+
-X dkhalife.com/tasks/core/internal/version.CommitHash=${{ github.sha }}" \
38+
./...
3439
3540
- name: Install lint tool
3641
run: |

.github/workflows/full-release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ jobs:
190190
GITHUB_RUN_NUMBER: ${{ github.run_number }}
191191
GITHUB_RUN_ATTEMPT: ${{ github.run_attempt }}
192192
GITHUB_RUN_ID: ${{ github.run_id }}
193+
APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }}
193194
run: |
194195
cd android
195196
./gradlew bundleRelease

.goreleaser.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ builds:
66
- dir: apiserver
77
env:
88
- CGO_ENABLED=0
9+
ldflags:
10+
- -s -w
11+
- -X dkhalife.com/tasks/core/internal/version.Version={{.Version}}
12+
- -X dkhalife.com/tasks/core/internal/version.BuildNumber={{.Env.GITHUB_RUN_NUMBER}}
13+
- -X dkhalife.com/tasks/core/internal/version.CommitHash={{.ShortCommit}}
914
targets:
1015
- linux_amd64_v1
1116
- darwin_arm64

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ In the [config](./apiserver/config/) directory are a couple of starter configura
9595

9696
**Note:** You can set Entra ID settings and database credentials using environment variables for improved security and flexibility.
9797

98+
### Telemetry (Application Insights)
99+
100+
Task Wizard supports optional Application Insights telemetry for both the API server and Android app. All events are sent as CustomEvents and include build number, commit hash, and component identifiers.
101+
102+
**API Server:** Set the `APPINSIGHTS_CONNECTION_STRING` environment variable. When not set, telemetry is silently disabled.
103+
104+
**Android App:** Telemetry is **disabled by default**. Users can opt in via Settings → Analytics. When disabled, the app sends a `DNT: 1` header on all API requests, which the backend respects by skipping request telemetry for that user. An additional "Debug logging" sub-toggle sends more detailed diagnostic data when enabled.
105+
98106
### Database Configuration
99107

100108
Task Wizard supports both SQLite and MySQL databases. By default, it uses SQLite.
@@ -177,6 +185,12 @@ The configuration files are yaml mappings with the following values:
177185
| `scheduler_jobs.overdue_frequency` | `24h` | The interval for sending overdue notifications. |
178186
| `scheduler_jobs.notification_cleanup` | `10m` | The interval for cleaning up sent notifications. |
179187

188+
### Telemetry Configuration
189+
190+
| Environment Variable | Default Value | Description |
191+
|-------------------------------------|---------------|-----------------------------------------------------------------------------|
192+
| `APPINSIGHTS_CONNECTION_STRING` | (empty) | Azure Application Insights connection string. When empty, telemetry is disabled. |
193+
180194

181195
## 🛠️ Development
182196

android/app/build.gradle.kts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import java.io.ByteArrayOutputStream
12
import java.time.LocalDate
23
import java.time.format.DateTimeFormatter
34
import java.util.Properties
@@ -29,18 +30,34 @@ fun calculateVersion(): Pair<Int, String> {
2930
return Pair(runNumber, versionName)
3031
}
3132

33+
fun resolveGitSha(): String {
34+
return try {
35+
val output = ByteArrayOutputStream()
36+
exec {
37+
commandLine("git", "rev-parse", "HEAD")
38+
standardOutput = output
39+
isIgnoreExitValue = true
40+
}
41+
output.toString().trim().ifBlank { "local" }
42+
} catch (e: Exception) {
43+
"local"
44+
}
45+
}
46+
3247
android {
3348
namespace = "com.dkhalife.tasks"
3449
compileSdk = 36
3550

3651
val (calculatedVersionCode, calculatedVersionName) = calculateVersion()
52+
val gitSha = resolveGitSha()
3753

3854
defaultConfig {
3955
applicationId = "com.dkhalife.tasks"
4056
minSdk = 34
4157
targetSdk = 36
4258
versionCode = calculatedVersionCode
4359
versionName = calculatedVersionName
60+
buildConfigField("String", "GIT_SHA", "\"$gitSha\"")
4461

4562
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
4663
}
@@ -80,6 +97,19 @@ android {
8097
if (releaseSigningConfig.storeFile != null) {
8198
signingConfig = releaseSigningConfig
8299
}
100+
101+
// Note: connection string is embedded in the APK. App Insights ingestion keys are
102+
// low-sensitivity (write-only, no read access to data). If spoofed telemetry is a
103+
// concern, consider proxying ingestion through the backend.
104+
val appInsightsKey = localProperties.getProperty("APPINSIGHTS_CONNECTION_STRING")
105+
?: System.getenv("APPINSIGHTS_CONNECTION_STRING") ?: ""
106+
buildConfigField("String", "APPINSIGHTS_CONNECTION_STRING", "\"$appInsightsKey\"")
107+
}
108+
109+
debug {
110+
val appInsightsKey = localProperties.getProperty("APPINSIGHTS_CONNECTION_STRING")
111+
?: System.getenv("APPINSIGHTS_CONNECTION_STRING") ?: ""
112+
buildConfigField("String", "APPINSIGHTS_CONNECTION_STRING", "\"$appInsightsKey\"")
83113
}
84114
}
85115

@@ -138,6 +168,10 @@ dependencies {
138168
implementation(libs.androidx.glance.appwidget)
139169
implementation(libs.androidx.glance.material3)
140170

171+
implementation(libs.opentelemetry.api)
172+
implementation(libs.opentelemetry.sdk)
173+
implementation(libs.opentelemetry.exporter.logging)
174+
141175
testImplementation(libs.junit)
142176
androidTestImplementation(libs.androidx.junit)
143177
androidTestImplementation(libs.androidx.espresso.core)

android/app/src/main/java/com/dkhalife/tasks/MainActivity.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.dkhalife.tasks.data.GroupingRepository
1010
import com.dkhalife.tasks.data.SwipeAction
1111
import com.dkhalife.tasks.data.SwipeActionsRepository
1212
import com.dkhalife.tasks.data.TaskGrouping
13+
import com.dkhalife.tasks.data.TelemetryRepository
1314
import com.dkhalife.tasks.data.ThemeMode
1415
import com.dkhalife.tasks.data.ThemeRepository
1516
import com.dkhalife.tasks.data.calendar.CalendarRepository
@@ -19,6 +20,7 @@ import com.dkhalife.tasks.ui.theme.TaskWizardTheme
1920
import com.dkhalife.tasks.ui.widget.TaskListWidget
2021
import com.dkhalife.tasks.ui.widget.quickadd.QuickAddWidget
2122
import com.dkhalife.tasks.viewmodel.AuthViewModel
23+
import com.dkhalife.tasks.telemetry.TelemetryManager
2224
import dagger.hilt.android.AndroidEntryPoint
2325
import javax.inject.Inject
2426

@@ -37,6 +39,12 @@ class MainActivity : ComponentActivity() {
3739
@Inject
3840
lateinit var swipeActionsRepository: SwipeActionsRepository
3941

42+
@Inject
43+
lateinit var telemetryRepository: TelemetryRepository
44+
45+
@Inject
46+
lateinit var telemetryManager: TelemetryManager
47+
4048
override fun onCreate(savedInstanceState: Bundle?) {
4149
super.onCreate(savedInstanceState)
4250
enableEdgeToEdge()
@@ -49,6 +57,8 @@ class MainActivity : ComponentActivity() {
4957
var taskGrouping by remember { mutableStateOf(groupingRepository.getTaskGrouping()) }
5058
var calendarSyncEnabled by remember { mutableStateOf(calendarRepository.isCalendarSyncEnabled()) }
5159
var swipeSettings by remember { mutableStateOf(swipeActionsRepository.getSettings()) }
60+
var telemetryEnabled by remember { mutableStateOf(telemetryRepository.isTelemetryEnabled()) }
61+
var debugLoggingEnabled by remember { mutableStateOf(telemetryRepository.isDebugLoggingEnabled()) }
5262

5363
TaskWizardTheme(themeMode = themeMode) {
5464
val authViewModel: AuthViewModel = hiltViewModel()
@@ -91,6 +101,19 @@ class MainActivity : ComponentActivity() {
91101
swipeActionsRepository.setDeleteConfirmationEnabled(enabled)
92102
swipeSettings = swipeSettings.copy(deleteConfirmationEnabled = enabled)
93103
},
104+
telemetryEnabled = telemetryEnabled,
105+
onTelemetryEnabledChanged = { enabled ->
106+
telemetryRepository.setTelemetryEnabled(enabled)
107+
telemetryEnabled = enabled
108+
if (enabled) {
109+
telemetryManager.initialize(this@MainActivity)
110+
}
111+
},
112+
debugLoggingEnabled = debugLoggingEnabled,
113+
onDebugLoggingEnabledChanged = { enabled ->
114+
telemetryRepository.setDebugLoggingEnabled(enabled)
115+
debugLoggingEnabled = enabled
116+
},
94117
initialTaskId = initialTaskId,
95118
createTask = createTask
96119
)

android/app/src/main/java/com/dkhalife/tasks/TaskWizardApplication.kt

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.app.Application
44
import androidx.work.Configuration
55
import com.dkhalife.tasks.auth.AuthManager
66
import com.dkhalife.tasks.data.sync.TaskSyncWorkerFactory
7+
import com.dkhalife.tasks.telemetry.TelemetryManager
78
import com.microsoft.identity.client.IPublicClientApplication
89
import com.microsoft.identity.client.ISingleAccountPublicClientApplication
910
import com.microsoft.identity.client.PublicClientApplication
@@ -20,16 +21,38 @@ class TaskWizardApplication : Application(), Configuration.Provider {
2021
@Inject
2122
lateinit var taskSyncWorkerFactory: TaskSyncWorkerFactory
2223

24+
@Inject
25+
lateinit var telemetryManager: TelemetryManager
26+
2327
override val workManagerConfiguration: Configuration
2428
get() = Configuration.Builder()
2529
.setWorkerFactory(taskSyncWorkerFactory)
2630
.build()
2731

2832
override fun onCreate() {
2933
super.onCreate()
34+
telemetryManager.initialize(this)
35+
setupCrashHandler()
3036
initializeMsal()
3137
}
3238

39+
private fun setupCrashHandler() {
40+
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
41+
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
42+
try {
43+
telemetryManager.trackException(throwable, mapOf("source" to "uncaught_exception"))
44+
try {
45+
Thread.sleep(2000)
46+
} catch (_: InterruptedException) {
47+
Thread.currentThread().interrupt()
48+
}
49+
} catch (_: Exception) {
50+
} finally {
51+
defaultHandler?.uncaughtException(thread, throwable)
52+
}
53+
}
54+
}
55+
3356
private fun initializeMsal() {
3457
PublicClientApplication.createSingleAccountPublicClientApplication(
3558
this,
@@ -41,7 +64,7 @@ class TaskWizardApplication : Application(), Configuration.Provider {
4164
}
4265

4366
override fun onError(exception: MsalException) {
44-
android.util.Log.e(TAG, "Failed to initialize MSAL", exception)
67+
telemetryManager.logError(TAG, "Failed to initialize MSAL", exception)
4568
}
4669
}
4770
)
@@ -61,7 +84,7 @@ class TaskWizardApplication : Application(), Configuration.Provider {
6184
}
6285

6386
override fun onError(exception: MsalException) {
64-
android.util.Log.e(TAG, "Failed to load current account", exception)
87+
telemetryManager.logError(TAG, "Failed to load current account", exception)
6588
}
6689
})
6790
}

android/app/src/main/java/com/dkhalife/tasks/api/AuthInterceptor.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.dkhalife.tasks.api
22

33
import com.dkhalife.tasks.auth.AuthTokenProvider
4+
import com.dkhalife.tasks.telemetry.TelemetryManager
45
import kotlinx.coroutines.runBlocking
56
import okhttp3.Authenticator
67
import okhttp3.Interceptor
@@ -9,8 +10,13 @@ import okhttp3.Response
910
import okhttp3.Route
1011

1112
class AuthInterceptor(
12-
private val tokenProvider: AuthTokenProvider
13+
private val tokenProvider: AuthTokenProvider,
14+
private val telemetryManager: TelemetryManager
1315
) : Interceptor, Authenticator {
16+
companion object {
17+
private const val TAG = "AuthInterceptor"
18+
}
19+
1420
override fun intercept(chain: Interceptor.Chain): Response {
1521
val token = tokenProvider.getCachedAccessToken()
1622

@@ -27,10 +33,14 @@ class AuthInterceptor(
2733

2834
override fun authenticate(route: Route?, response: Response): Request? {
2935
if (response.request.header("Authorization-Retry") != null) {
36+
telemetryManager.logWarning(TAG, "Auth retry exhausted")
3037
return null
3138
}
3239

33-
val freshToken = runBlocking { tokenProvider.getAccessToken() } ?: return null
40+
val freshToken = runBlocking { tokenProvider.getAccessToken() } ?: run {
41+
telemetryManager.logWarning(TAG, "Token refresh failed")
42+
return null
43+
}
3444

3545
return response.request.newBuilder()
3646
.removeHeader("Authorization")
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.dkhalife.tasks.api
2+
3+
import android.content.SharedPreferences
4+
import com.dkhalife.tasks.data.AppPreferences
5+
import okhttp3.Interceptor
6+
import okhttp3.Response
7+
8+
class DoNotTrackInterceptor(
9+
private val sharedPreferences: SharedPreferences
10+
) : Interceptor {
11+
override fun intercept(chain: Interceptor.Chain): Response {
12+
val telemetryEnabled = sharedPreferences.getBoolean(AppPreferences.KEY_TELEMETRY_ENABLED, false)
13+
14+
val request = if (!telemetryEnabled) {
15+
chain.request().newBuilder()
16+
.header("DNT", "1")
17+
.build()
18+
} else {
19+
chain.request()
20+
}
21+
22+
return chain.proceed(request)
23+
}
24+
}

android/app/src/main/java/com/dkhalife/tasks/auth/AuthManager.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ import com.microsoft.identity.client.IAuthenticationResult
99
import com.microsoft.identity.client.ISingleAccountPublicClientApplication
1010
import com.microsoft.identity.client.SilentAuthenticationCallback
1111
import com.microsoft.identity.client.exception.MsalException
12+
import com.dkhalife.tasks.telemetry.TelemetryManager
1213
import javax.inject.Inject
1314
import javax.inject.Singleton
1415

1516
@Singleton
16-
class AuthManager @Inject constructor() : AuthTokenProvider {
17+
class AuthManager @Inject constructor(
18+
private val telemetryManager: TelemetryManager
19+
) : AuthTokenProvider {
1720

1821
private var singleAccountApp: ISingleAccountPublicClientApplication? = null
1922

@@ -98,6 +101,7 @@ class AuthManager @Inject constructor() : AuthTokenProvider {
98101
cacheToken(result)
99102
result.accessToken
100103
} catch (e: MsalException) {
104+
telemetryManager.logWarning(TAG, "Silent token acquire failed: ${e.message}", e)
101105
null
102106
}
103107
}
@@ -116,7 +120,10 @@ class AuthManager @Inject constructor() : AuthTokenProvider {
116120
}
117121

118122
fun signIn(activity: Activity, callback: AuthenticationCallback) {
119-
val app = singleAccountApp ?: return
123+
val app = singleAccountApp ?: run {
124+
telemetryManager.logError(TAG, "Sign-in failed: singleAccountApp not initialized")
125+
return
126+
}
120127

121128
val params = AcquireTokenParameters.Builder()
122129
.startAuthorizationFromActivity(activity)
@@ -142,7 +149,10 @@ class AuthManager @Inject constructor() : AuthTokenProvider {
142149
}
143150

144151
fun signOut(callback: ISingleAccountPublicClientApplication.SignOutCallback) {
145-
val app = singleAccountApp ?: return
152+
val app = singleAccountApp ?: run {
153+
telemetryManager.logError(TAG, "Sign-out failed: singleAccountApp not initialized")
154+
return
155+
}
146156
app.signOut(object : ISingleAccountPublicClientApplication.SignOutCallback {
147157
override fun onSignOut() {
148158
updateAccount(null)
@@ -161,6 +171,7 @@ class AuthManager @Inject constructor() : AuthTokenProvider {
161171
}
162172

163173
companion object {
174+
private const val TAG = "AuthManager"
164175
private const val TOKEN_SKEW_MS = 120_000L
165176

166177
val REQUIRED_SCOPES: List<String>

0 commit comments

Comments
 (0)