From 6e6c924152013efe8bbabdc00250aa06a83116c5 Mon Sep 17 00:00:00 2001 From: Dany Khalife Date: Thu, 26 Mar 2026 19:44:19 -0700 Subject: [PATCH 1/2] add telemetry through app insights --- .github/workflows/api-build.yml | 7 +- .github/workflows/full-release.yml | 1 + .goreleaser.yaml | 5 + README.md | 14 + android/app/build.gradle.kts | 31 ++ .../java/com/dkhalife/tasks/MainActivity.kt | 16 ++ .../dkhalife/tasks/TaskWizardApplication.kt | 18 +- .../com/dkhalife/tasks/api/AuthInterceptor.kt | 15 +- .../tasks/api/DoNotTrackInterceptor.kt | 24 ++ .../com/dkhalife/tasks/auth/AuthManager.kt | 17 +- .../com/dkhalife/tasks/data/AppPreferences.kt | 3 + .../tasks/data/TelemetryRepository.kt | 27 ++ .../tasks/data/calendar/CalendarRepository.kt | 7 +- .../tasks/data/calendar/CalendarSyncEngine.kt | 13 +- .../tasks/data/sync/TaskSyncWorker.kt | 16 +- .../tasks/data/sync/TaskSyncWorkerFactory.kt | 6 +- .../tasks/data/widget/WidgetSyncEngine.kt | 15 +- .../com/dkhalife/tasks/di/NetworkModule.kt | 9 +- .../dkhalife/tasks/repo/LabelRepository.kt | 16 +- .../com/dkhalife/tasks/repo/TaskRepository.kt | 30 +- .../com/dkhalife/tasks/repo/UserRepository.kt | 12 +- .../telemetry/AzureMonitorSpanExporter.kt | 174 +++++++++++ .../tasks/telemetry/TelemetryManager.kt | 271 ++++++++++++++++++ .../tasks/ui/navigation/AppNavigation.kt | 10 +- .../tasks/ui/screen/SettingsScreen.kt | 67 ++++- .../com/dkhalife/tasks/utils/SoundManager.kt | 19 +- .../dkhalife/tasks/viewmodel/AuthViewModel.kt | 10 +- .../tasks/viewmodel/LabelViewModel.kt | 28 +- .../tasks/viewmodel/TaskFormViewModel.kt | 23 +- .../tasks/viewmodel/TaskListViewModel.kt | 43 ++- .../com/dkhalife/tasks/ws/WebSocketManager.kt | 24 +- android/app/src/main/res/values/strings.xml | 8 + android/gradle/libs.versions.toml | 4 + apiserver/go.mod | 3 + apiserver/go.sum | 24 ++ apiserver/internal/apis/label.go | 5 + apiserver/internal/apis/log.go | 6 +- apiserver/internal/apis/task.go | 17 ++ apiserver/internal/apis/user.go | 3 + apiserver/internal/middleware/auth/auth.go | 3 + apiserver/internal/services/labels/label.go | 8 + .../services/notifications/notifications.go | 4 + .../internal/services/scheduler/scheduler.go | 2 + apiserver/internal/services/tasks/task.go | 37 +++ apiserver/internal/services/users/user.go | 3 + apiserver/internal/telemetry/appinsights.go | 108 +++++++ .../internal/telemetry/appinsights_test.go | 63 ++++ apiserver/internal/telemetry/telemetry.go | 105 +++++++ apiserver/internal/utils/database/database.go | 2 + .../internal/utils/middleware/middleware.go | 24 ++ apiserver/internal/version/version.go | 29 ++ apiserver/internal/ws/server.go | 10 + apiserver/main.go | 4 + frontend/src/views/PrivacyPolicy.tsx | 41 ++- 54 files changed, 1425 insertions(+), 59 deletions(-) create mode 100644 android/app/src/main/java/com/dkhalife/tasks/api/DoNotTrackInterceptor.kt create mode 100644 android/app/src/main/java/com/dkhalife/tasks/data/TelemetryRepository.kt create mode 100644 android/app/src/main/java/com/dkhalife/tasks/telemetry/AzureMonitorSpanExporter.kt create mode 100644 android/app/src/main/java/com/dkhalife/tasks/telemetry/TelemetryManager.kt create mode 100644 apiserver/internal/telemetry/appinsights.go create mode 100644 apiserver/internal/telemetry/appinsights_test.go create mode 100644 apiserver/internal/telemetry/telemetry.go create mode 100644 apiserver/internal/version/version.go diff --git a/.github/workflows/api-build.yml b/.github/workflows/api-build.yml index 093f794b..4a19315e 100644 --- a/.github/workflows/api-build.yml +++ b/.github/workflows/api-build.yml @@ -30,7 +30,12 @@ jobs: - name: Build run: | cd apiserver - go build -v ./... + VERSION=$(date -u +'%y%m%d').00.1 + go build -v \ + -ldflags "-X dkhalife.com/tasks/core/internal/version.Version=${VERSION} \ + -X dkhalife.com/tasks/core/internal/version.BuildNumber=0 \ + -X dkhalife.com/tasks/core/internal/version.CommitHash=${{ github.sha }}" \ + ./... - name: Install lint tool run: | diff --git a/.github/workflows/full-release.yml b/.github/workflows/full-release.yml index f77eac7c..ced82b34 100644 --- a/.github/workflows/full-release.yml +++ b/.github/workflows/full-release.yml @@ -190,6 +190,7 @@ jobs: GITHUB_RUN_NUMBER: ${{ github.run_number }} GITHUB_RUN_ATTEMPT: ${{ github.run_attempt }} GITHUB_RUN_ID: ${{ github.run_id }} + APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }} run: | cd android ./gradlew bundleRelease diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6b88f806..8d01d5ad 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -6,6 +6,11 @@ builds: - dir: apiserver env: - CGO_ENABLED=0 + ldflags: + - -s -w + - -X dkhalife.com/tasks/core/internal/version.Version={{.Version}} + - -X dkhalife.com/tasks/core/internal/version.BuildNumber={{.Env.GITHUB_RUN_NUMBER}} + - -X dkhalife.com/tasks/core/internal/version.CommitHash={{.ShortCommit}} targets: - linux_amd64_v1 - darwin_arm64 diff --git a/README.md b/README.md index 861cb0f9..d88aea06 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,14 @@ In the [config](./apiserver/config/) directory are a couple of starter configura **Note:** You can set Entra ID settings and database credentials using environment variables for improved security and flexibility. +### Telemetry (Application Insights) + +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. + +**API Server:** Set the `APPINSIGHTS_CONNECTION_STRING` environment variable. When not set, telemetry is silently disabled. + +**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. + ### Database Configuration 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: | `scheduler_jobs.overdue_frequency` | `24h` | The interval for sending overdue notifications. | | `scheduler_jobs.notification_cleanup` | `10m` | The interval for cleaning up sent notifications. | +### Telemetry Configuration + +| Environment Variable | Default Value | Description | +|-------------------------------------|---------------|-----------------------------------------------------------------------------| +| `APPINSIGHTS_CONNECTION_STRING` | (empty) | Azure Application Insights connection string. When empty, telemetry is disabled. | + ## 🛠️ Development diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a2b367b5..fce4cea5 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,4 @@ +import java.io.ByteArrayOutputStream import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Properties @@ -29,11 +30,26 @@ fun calculateVersion(): Pair { return Pair(runNumber, versionName) } +fun resolveGitSha(): String { + return try { + val output = ByteArrayOutputStream() + exec { + commandLine("git", "rev-parse", "HEAD") + standardOutput = output + isIgnoreExitValue = true + } + output.toString().trim().ifBlank { "local" } + } catch (e: Exception) { + "local" + } +} + android { namespace = "com.dkhalife.tasks" compileSdk = 36 val (calculatedVersionCode, calculatedVersionName) = calculateVersion() + val gitSha = resolveGitSha() defaultConfig { applicationId = "com.dkhalife.tasks" @@ -41,6 +57,7 @@ android { targetSdk = 36 versionCode = calculatedVersionCode versionName = calculatedVersionName + buildConfigField("String", "GIT_SHA", "\"$gitSha\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -80,6 +97,16 @@ android { if (releaseSigningConfig.storeFile != null) { signingConfig = releaseSigningConfig } + + val appInsightsKey = localProperties.getProperty("APPINSIGHTS_CONNECTION_STRING") + ?: System.getenv("APPINSIGHTS_CONNECTION_STRING") ?: "" + buildConfigField("String", "APPINSIGHTS_CONNECTION_STRING", "\"$appInsightsKey\"") + } + + debug { + val appInsightsKey = localProperties.getProperty("APPINSIGHTS_CONNECTION_STRING") + ?: System.getenv("APPINSIGHTS_CONNECTION_STRING") ?: "" + buildConfigField("String", "APPINSIGHTS_CONNECTION_STRING", "\"$appInsightsKey\"") } } @@ -138,6 +165,10 @@ dependencies { implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.material3) + implementation(libs.opentelemetry.api) + implementation(libs.opentelemetry.sdk) + implementation(libs.opentelemetry.exporter.logging) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/android/app/src/main/java/com/dkhalife/tasks/MainActivity.kt b/android/app/src/main/java/com/dkhalife/tasks/MainActivity.kt index 29d0985b..542cef03 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/MainActivity.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/MainActivity.kt @@ -10,6 +10,7 @@ import com.dkhalife.tasks.data.GroupingRepository import com.dkhalife.tasks.data.SwipeAction import com.dkhalife.tasks.data.SwipeActionsRepository import com.dkhalife.tasks.data.TaskGrouping +import com.dkhalife.tasks.data.TelemetryRepository import com.dkhalife.tasks.data.ThemeMode import com.dkhalife.tasks.data.ThemeRepository import com.dkhalife.tasks.data.calendar.CalendarRepository @@ -37,6 +38,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var swipeActionsRepository: SwipeActionsRepository + @Inject + lateinit var telemetryRepository: TelemetryRepository + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -49,6 +53,8 @@ class MainActivity : ComponentActivity() { var taskGrouping by remember { mutableStateOf(groupingRepository.getTaskGrouping()) } var calendarSyncEnabled by remember { mutableStateOf(calendarRepository.isCalendarSyncEnabled()) } var swipeSettings by remember { mutableStateOf(swipeActionsRepository.getSettings()) } + var telemetryEnabled by remember { mutableStateOf(telemetryRepository.isTelemetryEnabled()) } + var debugLoggingEnabled by remember { mutableStateOf(telemetryRepository.isDebugLoggingEnabled()) } TaskWizardTheme(themeMode = themeMode) { val authViewModel: AuthViewModel = hiltViewModel() @@ -91,6 +97,16 @@ class MainActivity : ComponentActivity() { swipeActionsRepository.setDeleteConfirmationEnabled(enabled) swipeSettings = swipeSettings.copy(deleteConfirmationEnabled = enabled) }, + telemetryEnabled = telemetryEnabled, + onTelemetryEnabledChanged = { enabled -> + telemetryRepository.setTelemetryEnabled(enabled) + telemetryEnabled = enabled + }, + debugLoggingEnabled = debugLoggingEnabled, + onDebugLoggingEnabledChanged = { enabled -> + telemetryRepository.setDebugLoggingEnabled(enabled) + debugLoggingEnabled = enabled + }, initialTaskId = initialTaskId, createTask = createTask ) diff --git a/android/app/src/main/java/com/dkhalife/tasks/TaskWizardApplication.kt b/android/app/src/main/java/com/dkhalife/tasks/TaskWizardApplication.kt index 3dd6f2e2..c3f5062c 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/TaskWizardApplication.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/TaskWizardApplication.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.work.Configuration import com.dkhalife.tasks.auth.AuthManager import com.dkhalife.tasks.data.sync.TaskSyncWorkerFactory +import com.dkhalife.tasks.telemetry.TelemetryManager import com.microsoft.identity.client.IPublicClientApplication import com.microsoft.identity.client.ISingleAccountPublicClientApplication import com.microsoft.identity.client.PublicClientApplication @@ -20,6 +21,9 @@ class TaskWizardApplication : Application(), Configuration.Provider { @Inject lateinit var taskSyncWorkerFactory: TaskSyncWorkerFactory + @Inject + lateinit var telemetryManager: TelemetryManager + override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(taskSyncWorkerFactory) @@ -27,9 +31,19 @@ class TaskWizardApplication : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() + telemetryManager.initialize(this) + setupCrashHandler() initializeMsal() } + private fun setupCrashHandler() { + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + telemetryManager.trackException(throwable, mapOf("source" to "uncaught_exception")) + defaultHandler?.uncaughtException(thread, throwable) + } + } + private fun initializeMsal() { PublicClientApplication.createSingleAccountPublicClientApplication( this, @@ -41,7 +55,7 @@ class TaskWizardApplication : Application(), Configuration.Provider { } override fun onError(exception: MsalException) { - android.util.Log.e(TAG, "Failed to initialize MSAL", exception) + telemetryManager.logError(TAG, "Failed to initialize MSAL", exception) } } ) @@ -61,7 +75,7 @@ class TaskWizardApplication : Application(), Configuration.Provider { } override fun onError(exception: MsalException) { - android.util.Log.e(TAG, "Failed to load current account", exception) + telemetryManager.logError(TAG, "Failed to load current account", exception) } }) } diff --git a/android/app/src/main/java/com/dkhalife/tasks/api/AuthInterceptor.kt b/android/app/src/main/java/com/dkhalife/tasks/api/AuthInterceptor.kt index e48a7365..874ed639 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/api/AuthInterceptor.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/api/AuthInterceptor.kt @@ -1,6 +1,8 @@ package com.dkhalife.tasks.api +import android.util.Log import com.dkhalife.tasks.auth.AuthTokenProvider +import com.dkhalife.tasks.telemetry.TelemetryManager import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Interceptor @@ -9,8 +11,13 @@ import okhttp3.Response import okhttp3.Route class AuthInterceptor( - private val tokenProvider: AuthTokenProvider + private val tokenProvider: AuthTokenProvider, + private val telemetryManager: TelemetryManager ) : Interceptor, Authenticator { + companion object { + private const val TAG = "AuthInterceptor" + } + override fun intercept(chain: Interceptor.Chain): Response { val token = tokenProvider.getCachedAccessToken() @@ -27,10 +34,14 @@ class AuthInterceptor( override fun authenticate(route: Route?, response: Response): Request? { if (response.request.header("Authorization-Retry") != null) { + telemetryManager.logWarning(TAG, "Auth retry exhausted") return null } - val freshToken = runBlocking { tokenProvider.getAccessToken() } ?: return null + val freshToken = runBlocking { tokenProvider.getAccessToken() } ?: run { + telemetryManager.logWarning(TAG, "Token refresh failed") + return null + } return response.request.newBuilder() .removeHeader("Authorization") diff --git a/android/app/src/main/java/com/dkhalife/tasks/api/DoNotTrackInterceptor.kt b/android/app/src/main/java/com/dkhalife/tasks/api/DoNotTrackInterceptor.kt new file mode 100644 index 00000000..e5327470 --- /dev/null +++ b/android/app/src/main/java/com/dkhalife/tasks/api/DoNotTrackInterceptor.kt @@ -0,0 +1,24 @@ +package com.dkhalife.tasks.api + +import android.content.SharedPreferences +import com.dkhalife.tasks.data.AppPreferences +import okhttp3.Interceptor +import okhttp3.Response + +class DoNotTrackInterceptor( + private val sharedPreferences: SharedPreferences +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val telemetryEnabled = sharedPreferences.getBoolean(AppPreferences.KEY_TELEMETRY_ENABLED, false) + + val request = if (!telemetryEnabled) { + chain.request().newBuilder() + .addHeader("DNT", "1") + .build() + } else { + chain.request() + } + + return chain.proceed(request) + } +} diff --git a/android/app/src/main/java/com/dkhalife/tasks/auth/AuthManager.kt b/android/app/src/main/java/com/dkhalife/tasks/auth/AuthManager.kt index ef71e694..3df984bb 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/auth/AuthManager.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/auth/AuthManager.kt @@ -9,11 +9,14 @@ import com.microsoft.identity.client.IAuthenticationResult import com.microsoft.identity.client.ISingleAccountPublicClientApplication import com.microsoft.identity.client.SilentAuthenticationCallback import com.microsoft.identity.client.exception.MsalException +import com.dkhalife.tasks.telemetry.TelemetryManager import javax.inject.Inject import javax.inject.Singleton @Singleton -class AuthManager @Inject constructor() : AuthTokenProvider { +class AuthManager @Inject constructor( + private val telemetryManager: TelemetryManager +) : AuthTokenProvider { private var singleAccountApp: ISingleAccountPublicClientApplication? = null @@ -98,6 +101,7 @@ class AuthManager @Inject constructor() : AuthTokenProvider { cacheToken(result) result.accessToken } catch (e: MsalException) { + telemetryManager.logWarning(TAG, "Silent token acquire failed: ${e.message}", e) null } } @@ -116,7 +120,10 @@ class AuthManager @Inject constructor() : AuthTokenProvider { } fun signIn(activity: Activity, callback: AuthenticationCallback) { - val app = singleAccountApp ?: return + val app = singleAccountApp ?: run { + telemetryManager.logError(TAG, "Sign-in failed: singleAccountApp not initialized") + return + } val params = AcquireTokenParameters.Builder() .startAuthorizationFromActivity(activity) @@ -142,7 +149,10 @@ class AuthManager @Inject constructor() : AuthTokenProvider { } fun signOut(callback: ISingleAccountPublicClientApplication.SignOutCallback) { - val app = singleAccountApp ?: return + val app = singleAccountApp ?: run { + telemetryManager.logError(TAG, "Sign-out failed: singleAccountApp not initialized") + return + } app.signOut(object : ISingleAccountPublicClientApplication.SignOutCallback { override fun onSignOut() { updateAccount(null) @@ -161,6 +171,7 @@ class AuthManager @Inject constructor() : AuthTokenProvider { } companion object { + private const val TAG = "AuthManager" private const val TOKEN_SKEW_MS = 120_000L val REQUIRED_SCOPES: List diff --git a/android/app/src/main/java/com/dkhalife/tasks/data/AppPreferences.kt b/android/app/src/main/java/com/dkhalife/tasks/data/AppPreferences.kt index 146c1a29..4781b960 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/data/AppPreferences.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/data/AppPreferences.kt @@ -10,6 +10,9 @@ object AppPreferences { const val KEY_SWIPE_START_TO_END_ACTION = "swipe_start_to_end_action" const val KEY_SWIPE_END_TO_START_ACTION = "swipe_end_to_start_action" const val KEY_SWIPE_DELETE_CONFIRMATION = "swipe_delete_confirmation" + const val KEY_TELEMETRY_ENABLED = "telemetry_enabled" + const val KEY_DEBUG_LOGGING_ENABLED = "debug_logging_enabled" + const val KEY_DEVICE_IDENTIFIER = "device_identifier" } enum class ThemeMode { diff --git a/android/app/src/main/java/com/dkhalife/tasks/data/TelemetryRepository.kt b/android/app/src/main/java/com/dkhalife/tasks/data/TelemetryRepository.kt new file mode 100644 index 00000000..495f2013 --- /dev/null +++ b/android/app/src/main/java/com/dkhalife/tasks/data/TelemetryRepository.kt @@ -0,0 +1,27 @@ +package com.dkhalife.tasks.data + +import android.content.SharedPreferences +import androidx.core.content.edit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TelemetryRepository @Inject constructor( + private val sharedPreferences: SharedPreferences +) { + fun isTelemetryEnabled(): Boolean { + return sharedPreferences.getBoolean(AppPreferences.KEY_TELEMETRY_ENABLED, false) + } + + fun setTelemetryEnabled(enabled: Boolean) { + sharedPreferences.edit { putBoolean(AppPreferences.KEY_TELEMETRY_ENABLED, enabled) } + } + + fun isDebugLoggingEnabled(): Boolean { + return sharedPreferences.getBoolean(AppPreferences.KEY_DEBUG_LOGGING_ENABLED, false) + } + + fun setDebugLoggingEnabled(enabled: Boolean) { + sharedPreferences.edit { putBoolean(AppPreferences.KEY_DEBUG_LOGGING_ENABLED, enabled) } + } +} diff --git a/android/app/src/main/java/com/dkhalife/tasks/data/calendar/CalendarRepository.kt b/android/app/src/main/java/com/dkhalife/tasks/data/calendar/CalendarRepository.kt index ebbde97f..105c07db 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/data/calendar/CalendarRepository.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/data/calendar/CalendarRepository.kt @@ -11,6 +11,7 @@ import androidx.work.WorkManager import com.dkhalife.tasks.auth.AuthManager import com.dkhalife.tasks.data.AppPreferences import com.dkhalife.tasks.data.sync.TaskSyncScheduler +import com.dkhalife.tasks.telemetry.TelemetryManager import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -23,7 +24,8 @@ class CalendarRepository @Inject constructor( private val sharedPreferences: SharedPreferences, private val calendarProviderClient: CalendarProviderClient, private val taskSyncScheduler: TaskSyncScheduler, - private val authManager: AuthManager + private val authManager: AuthManager, + private val telemetryManager: TelemetryManager ) { fun isCalendarSyncEnabled(): Boolean { @@ -63,6 +65,7 @@ class CalendarRepository @Inject constructor( sharedPreferences.edit { putBoolean(AppPreferences.KEY_CALENDAR_SYNC, true) } Result.success(Unit) } catch (e: Exception) { + telemetryManager.logError(TAG, "Failed to enable calendar sync: ${e.message}", e) Result.failure(e) } } @@ -85,11 +88,13 @@ class CalendarRepository @Inject constructor( taskSyncScheduler.cancelIfUnneeded(workManager, appContext) Result.success(Unit) } catch (e: Exception) { + telemetryManager.logError(TAG, "Failed to disable calendar sync: ${e.message}", e) Result.failure(e) } } companion object { + private const val TAG = "CalendarRepository" private const val FALLBACK_ACCOUNT_NAME = "Task Wizard" internal const val CALENDAR_DISPLAY_NAME = "Task Wizard" internal val CALENDAR_COLOR = Color.parseColor("#4A90D9") diff --git a/android/app/src/main/java/com/dkhalife/tasks/data/calendar/CalendarSyncEngine.kt b/android/app/src/main/java/com/dkhalife/tasks/data/calendar/CalendarSyncEngine.kt index 6eab94fa..f74177cd 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/data/calendar/CalendarSyncEngine.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/data/calendar/CalendarSyncEngine.kt @@ -5,6 +5,7 @@ import android.content.SharedPreferences import com.dkhalife.tasks.data.AppPreferences import com.dkhalife.tasks.data.sync.SyncEngine import com.dkhalife.tasks.model.Task +import com.dkhalife.tasks.telemetry.TelemetryManager import java.time.ZonedDateTime import javax.inject.Inject import javax.inject.Singleton @@ -13,7 +14,8 @@ import javax.inject.Singleton class CalendarSyncEngine @Inject constructor( private val calendarProviderClient: CalendarProviderClient, private val calendarRepository: CalendarRepository, - private val sharedPreferences: SharedPreferences + private val sharedPreferences: SharedPreferences, + private val telemetryManager: TelemetryManager ) : SyncEngine { override suspend fun sync(context: Context, tasks: List) { @@ -33,7 +35,10 @@ class CalendarSyncEngine @Inject constructor( ) calendarId = calendarProviderClient.getCalendarId( context.contentResolver, accountName - ) ?: return + ) ?: run { + telemetryManager.logError(TAG, "Calendar not found after creation for account=$accountName") + return + } } val existingEvents = calendarProviderClient.getEventsBySyncData(context.contentResolver, calendarId) @@ -68,12 +73,14 @@ class CalendarSyncEngine @Inject constructor( if (dateString.isNullOrBlank()) return null return try { ZonedDateTime.parse(dateString).toInstant().toEpochMilli() - } catch (_: Exception) { + } catch (e: Exception) { + telemetryManager.logWarning(TAG, "Failed to parse date: $dateString: ${e.message}", e) null } } companion object { + private const val TAG = "CalendarSyncEngine" const val EVENT_DURATION_MS = 15 * 60 * 1000L } } diff --git a/android/app/src/main/java/com/dkhalife/tasks/data/sync/TaskSyncWorker.kt b/android/app/src/main/java/com/dkhalife/tasks/data/sync/TaskSyncWorker.kt index f7f665e8..95892022 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/data/sync/TaskSyncWorker.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/data/sync/TaskSyncWorker.kt @@ -1,15 +1,18 @@ package com.dkhalife.tasks.data.sync import android.content.Context +import android.util.Log import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.dkhalife.tasks.api.TaskWizardApi +import com.dkhalife.tasks.telemetry.TelemetryManager class TaskSyncWorker( appContext: Context, workerParams: WorkerParameters, private val api: TaskWizardApi, - private val engines: List + private val engines: List, + private val telemetryManager: TelemetryManager ) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { @@ -21,16 +24,23 @@ class TaskSyncWorker( for (engine in engines) { try { engine.sync(applicationContext, tasks) - } catch (_: Exception) { + } catch (e: Exception) { + telemetryManager.logError(TAG, "Sync engine ${engine::class.simpleName} failed: ${e.message}", e) anyFailed = true } } if (anyFailed) Result.retry() else Result.success() } else { + telemetryManager.logError(TAG, "Failed to fetch tasks for sync: ${response.code()}") Result.retry() } - } catch (_: Exception) { + } catch (e: Exception) { + telemetryManager.logError(TAG, "Task sync failed: ${e.message}", e) Result.retry() } } + + companion object { + private const val TAG = "TaskSyncWorker" + } } diff --git a/android/app/src/main/java/com/dkhalife/tasks/data/sync/TaskSyncWorkerFactory.kt b/android/app/src/main/java/com/dkhalife/tasks/data/sync/TaskSyncWorkerFactory.kt index 779ae83f..b7544d95 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/data/sync/TaskSyncWorkerFactory.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/data/sync/TaskSyncWorkerFactory.kt @@ -5,13 +5,15 @@ import androidx.work.ListenableWorker import androidx.work.WorkerFactory import androidx.work.WorkerParameters import com.dkhalife.tasks.api.TaskWizardApi +import com.dkhalife.tasks.telemetry.TelemetryManager import javax.inject.Inject import javax.inject.Singleton @Singleton class TaskSyncWorkerFactory @Inject constructor( private val api: TaskWizardApi, - private val engines: List<@JvmSuppressWildcards SyncEngine> + private val engines: List<@JvmSuppressWildcards SyncEngine>, + private val telemetryManager: TelemetryManager ) : WorkerFactory() { override fun createWorker( @@ -20,7 +22,7 @@ class TaskSyncWorkerFactory @Inject constructor( workerParameters: WorkerParameters ): ListenableWorker? { if (workerClassName == TaskSyncWorker::class.java.name) { - return TaskSyncWorker(appContext, workerParameters, api, engines) + return TaskSyncWorker(appContext, workerParameters, api, engines, telemetryManager) } return null } diff --git a/android/app/src/main/java/com/dkhalife/tasks/data/widget/WidgetSyncEngine.kt b/android/app/src/main/java/com/dkhalife/tasks/data/widget/WidgetSyncEngine.kt index 55252533..545185be 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/data/widget/WidgetSyncEngine.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/data/widget/WidgetSyncEngine.kt @@ -1,12 +1,14 @@ package com.dkhalife.tasks.data.widget import android.content.Context +import android.util.Log import androidx.datastore.preferences.core.stringPreferencesKey import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.updateAll import com.dkhalife.tasks.data.sync.SyncEngine import com.dkhalife.tasks.model.Task +import com.dkhalife.tasks.telemetry.TelemetryManager import com.dkhalife.tasks.ui.widget.TaskListWidget import com.dkhalife.tasks.ui.widget.duetoday.DueTodayWidget import com.dkhalife.tasks.ui.widget.labelfilter.LabelFilterWidget @@ -18,7 +20,8 @@ import javax.inject.Singleton @Singleton class WidgetSyncEngine @Inject constructor( - private val gson: Gson + private val gson: Gson, + private val telemetryManager: TelemetryManager ) : SyncEngine { override suspend fun sync(context: Context, tasks: List) { @@ -34,7 +37,8 @@ class WidgetSyncEngine @Inject constructor( for (widgetType in widgetTypes) { val glanceIds = try { GlanceAppWidgetManager(context).getGlanceIds(widgetType) - } catch (_: Exception) { + } catch (e: Exception) { + telemetryManager.logWarning(TAG, "Failed to get Glance IDs for ${widgetType.simpleName}: ${e.message}", e) continue } @@ -52,14 +56,17 @@ class WidgetSyncEngine @Inject constructor( } companion object { + private const val TAG = "WidgetSyncEngine" val KEY_TASKS_JSON = stringPreferencesKey("widget_tasks_json") - fun deserializeTasks(gson: Gson, json: String?): List { + fun deserializeTasks(gson: Gson, json: String?, telemetryManager: TelemetryManager? = null): List { if (json.isNullOrBlank()) return emptyList() return try { val type = object : TypeToken>() {}.type gson.fromJson(json, type) - } catch (_: Exception) { + } catch (e: Exception) { + telemetryManager?.logWarning(TAG, "Failed to deserialize tasks: ${e.message}", e) + ?: Log.w(TAG, "Failed to deserialize tasks: ${e.message}", e) emptyList() } } diff --git a/android/app/src/main/java/com/dkhalife/tasks/di/NetworkModule.kt b/android/app/src/main/java/com/dkhalife/tasks/di/NetworkModule.kt index d6f870f1..bb210b65 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/di/NetworkModule.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/di/NetworkModule.kt @@ -3,14 +3,17 @@ package com.dkhalife.tasks.di import com.dkhalife.tasks.BuildConfig import com.dkhalife.tasks.api.ApiEndpointProvider import com.dkhalife.tasks.api.AuthInterceptor +import com.dkhalife.tasks.api.DoNotTrackInterceptor import com.dkhalife.tasks.api.TaskWizardApi import com.dkhalife.tasks.auth.AuthTokenProvider +import com.dkhalife.tasks.telemetry.TelemetryManager import com.google.gson.Gson import com.google.gson.GsonBuilder import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import android.content.SharedPreferences import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -27,9 +30,11 @@ object NetworkModule { @Provides @Singleton - fun provideOkHttpClient(tokenProvider: AuthTokenProvider): OkHttpClient { - val authInterceptor = AuthInterceptor(tokenProvider) + fun provideOkHttpClient(tokenProvider: AuthTokenProvider, sharedPreferences: SharedPreferences, telemetryManager: TelemetryManager): OkHttpClient { + val authInterceptor = AuthInterceptor(tokenProvider, telemetryManager) + val dntInterceptor = DoNotTrackInterceptor(sharedPreferences) val builder = OkHttpClient.Builder() + .addInterceptor(dntInterceptor) .addInterceptor(authInterceptor) .authenticator(authInterceptor) diff --git a/android/app/src/main/java/com/dkhalife/tasks/repo/LabelRepository.kt b/android/app/src/main/java/com/dkhalife/tasks/repo/LabelRepository.kt index f3f23c9e..efa2e5e2 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/repo/LabelRepository.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/repo/LabelRepository.kt @@ -2,6 +2,7 @@ package com.dkhalife.tasks.repo import com.dkhalife.tasks.api.TaskWizardApi import com.dkhalife.tasks.model.* +import com.dkhalife.tasks.telemetry.TelemetryManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject @@ -9,7 +10,8 @@ import javax.inject.Singleton @Singleton class LabelRepository @Inject constructor( - private val api: TaskWizardApi + private val api: TaskWizardApi, + private val telemetryManager: TelemetryManager ) { private val _labels = MutableStateFlow>(emptyList()) val labels: StateFlow> = _labels @@ -22,9 +24,11 @@ class LabelRepository @Inject constructor( _labels.value = labels Result.success(labels) } else { + telemetryManager.logError(TAG, "Failed to fetch labels: ${response.code()}") Result.failure(Exception("Failed to fetch labels: ${response.code()}")) } } catch (e: Exception) { + telemetryManager.logError(TAG, "Failed to fetch labels: ${e.message}", e) Result.failure(e) } } @@ -36,9 +40,11 @@ class LabelRepository @Inject constructor( refreshLabels() Result.success(response.body()!!.label) } else { + telemetryManager.logError(TAG, "Failed to create label: ${response.code()}") Result.failure(Exception("Failed to create label: ${response.code()}")) } } catch (e: Exception) { + telemetryManager.logError(TAG, "Failed to create label: ${e.message}", e) Result.failure(e) } } @@ -50,9 +56,11 @@ class LabelRepository @Inject constructor( refreshLabels() Result.success(Unit) } else { + telemetryManager.logError(TAG, "Failed to update label: ${response.code()}") Result.failure(Exception("Failed to update label: ${response.code()}")) } } catch (e: Exception) { + telemetryManager.logError(TAG, "Failed to update label: ${e.message}", e) Result.failure(e) } } @@ -64,9 +72,11 @@ class LabelRepository @Inject constructor( refreshLabels() Result.success(Unit) } else { + telemetryManager.logError(TAG, "Failed to delete label: ${response.code()}") Result.failure(Exception("Failed to delete label: ${response.code()}")) } } catch (e: Exception) { + telemetryManager.logError(TAG, "Failed to delete label: ${e.message}", e) Result.failure(e) } } @@ -74,4 +84,8 @@ class LabelRepository @Inject constructor( fun updateLabelsFromWebSocket(labels: List