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..866b7a28 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,19 @@ android { if (releaseSigningConfig.storeFile != null) { signingConfig = releaseSigningConfig } + + // Note: connection string is embedded in the APK. App Insights ingestion keys are + // low-sensitivity (write-only, no read access to data). If spoofed telemetry is a + // concern, consider proxying ingestion through the backend. + 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 +168,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..86dc5e86 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 @@ -19,6 +20,7 @@ import com.dkhalife.tasks.ui.theme.TaskWizardTheme import com.dkhalife.tasks.ui.widget.TaskListWidget import com.dkhalife.tasks.ui.widget.quickadd.QuickAddWidget import com.dkhalife.tasks.viewmodel.AuthViewModel +import com.dkhalife.tasks.telemetry.TelemetryManager import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -37,6 +39,12 @@ class MainActivity : ComponentActivity() { @Inject lateinit var swipeActionsRepository: SwipeActionsRepository + @Inject + lateinit var telemetryRepository: TelemetryRepository + + @Inject + lateinit var telemetryManager: TelemetryManager + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -49,6 +57,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 +101,19 @@ class MainActivity : ComponentActivity() { swipeActionsRepository.setDeleteConfirmationEnabled(enabled) swipeSettings = swipeSettings.copy(deleteConfirmationEnabled = enabled) }, + telemetryEnabled = telemetryEnabled, + onTelemetryEnabledChanged = { enabled -> + telemetryRepository.setTelemetryEnabled(enabled) + telemetryEnabled = enabled + if (enabled) { + telemetryManager.initialize(this@MainActivity) + } + }, + 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..fb4101e9 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,28 @@ 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 -> + try { + telemetryManager.trackException(throwable, mapOf("source" to "uncaught_exception")) + try { + Thread.sleep(2000) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } catch (_: Exception) { + } finally { + defaultHandler?.uncaughtException(thread, throwable) + } + } + } + private fun initializeMsal() { PublicClientApplication.createSingleAccountPublicClientApplication( this, @@ -41,7 +64,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 +84,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..588cf166 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,7 @@ package com.dkhalife.tasks.api import com.dkhalife.tasks.auth.AuthTokenProvider +import com.dkhalife.tasks.telemetry.TelemetryManager import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Interceptor @@ -9,8 +10,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 +33,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..f9a23dc0 --- /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() + .header("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..071a4eed 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 @@ -4,12 +4,14 @@ import android.content.Context 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 +23,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