Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/api-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/full-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import java.io.ByteArrayOutputStream
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Properties
Expand Down Expand Up @@ -29,18 +30,34 @@ fun calculateVersion(): Pair<Int, String> {
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"
minSdk = 34
targetSdk = 36
versionCode = calculatedVersionCode
versionName = calculatedVersionName
buildConfigField("String", "GIT_SHA", "\"$gitSha\"")

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down Expand Up @@ -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\"")
Comment thread
dkhalife marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions android/app/src/main/java/com/dkhalife/tasks/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,16 +21,38 @@ class TaskWizardApplication : Application(), Configuration.Provider {
@Inject
lateinit var taskSyncWorkerFactory: TaskSyncWorkerFactory

@Inject
lateinit var telemetryManager: TelemetryManager

override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(taskSyncWorkerFactory)
.build()

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,
Expand All @@ -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)
}
}
)
Expand All @@ -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)
}
})
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()

Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
17 changes: 14 additions & 3 deletions android/app/src/main/java/com/dkhalife/tasks/auth/AuthManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
}
}
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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<String>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading