Skip to content

Commit 04b4d2f

Browse files
authored
Add privacy-safe ORS usage analytics (#99)
1 parent 4b89e26 commit 04b4d2f

10 files changed

Lines changed: 260 additions & 44 deletions

File tree

‎app/src/main/java/org/nitri/opentopo/MapFragment.kt‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ import org.nitri.opentopo.overlay.GestureOverlay
5454
import org.nitri.opentopo.overlay.GestureOverlay.GestureCallback
5555
import org.nitri.opentopo.overlay.OverlayHelper
5656
import org.nitri.opentopo.analytics.AnalyticsProvider
57+
import org.nitri.opentopo.analytics.OrsOutcome
58+
import org.nitri.opentopo.analytics.OrsStartSource
59+
import org.nitri.opentopo.analytics.durationBucket
5760
import org.nitri.opentopo.util.MapOrientation
5861
import org.nitri.opentopo.util.OrientationSensor
5962
import org.nitri.opentopo.util.Utils
@@ -585,14 +588,14 @@ class MapFragment : Fragment(), LocationListener, PopupMenu.OnMenuItemClickListe
585588
val language = locale.language.lowercase()
586589
listener?.getOpenRouteServiceClient()?.let { client ->
587590
val directions = Directions(client, profile)
591+
val destinationCount = markers.size
592+
val startSource = if (currentLocation != null) OrsStartSource.CURRENT_LOCATION else OrsStartSource.MARKER_ONLY
593+
val startedAt = System.nanoTime() / 1_000_000
594+
val analytics = AnalyticsProvider.get(requireContext())
588595
directions.getRouteGpx(coordinates, language, object : Directions.RouteGpxResult {
589596
override fun onSuccess(gpx: String) {
590-
Log.d(TAG, "GPX: $gpx")
591-
// Analytics: route calculation succeeded (Play flavor reports it; FOSS no-ops)
592-
AnalyticsProvider.get(requireContext()).trackRouteCalculated(
593-
profile = profile,
594-
waypointCount = coordinates.size
595-
)
597+
Log.d(TAG, "ORS route GPX received (${gpx.length} chars)")
598+
trackRouteResult(OrsOutcome.SUCCESS)
596599
gpxViewModel?.markerCoordinates = markers
597600
gpxViewModel?.orsProfile = profile
598601

@@ -607,16 +610,26 @@ class MapFragment : Fragment(), LocationListener, PopupMenu.OnMenuItemClickListe
607610
}
608611
}
609612

610-
override fun onError(message: String) {
611-
Log.e(TAG, "Error fetching GPX: $message")
613+
override fun onEmpty() {
614+
trackRouteResult(OrsOutcome.EMPTY)
612615
context?.let { ctx ->
613616
Toast.makeText(
614617
ctx,
615-
"Error fetching GPX: $message",
618+
R.string.unable_to_calculate_route,
616619
Toast.LENGTH_LONG
617620
).show()
618621
}
619622
}
623+
624+
override fun onError(category: org.nitri.opentopo.analytics.OrsErrorCategory) {
625+
trackRouteResult(OrsOutcome.ERROR, category)
626+
context?.let { Toast.makeText(it, R.string.unable_to_calculate_route, Toast.LENGTH_LONG).show() }
627+
}
628+
629+
private fun trackRouteResult(outcome: OrsOutcome, category: org.nitri.opentopo.analytics.OrsErrorCategory? = null) {
630+
analytics.trackOrsRouteResult(outcome, profile, destinationCount,
631+
startSource, durationBucket(System.nanoTime() / 1_000_000 - startedAt), category)
632+
}
620633
}
621634
)
622635
}

‎app/src/main/java/org/nitri/opentopo/analytics/AnalyticsTracker.kt‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,17 @@ interface AnalyticsTracker {
2121
fileName: String?
2222
)
2323

24-
fun trackRouteCalculated(
25-
profile: String?,
26-
waypointCount: Int
27-
)
24+
fun trackOrsRouteResult(outcome: OrsOutcome, profile: String, destinationCount: Int,
25+
startSource: OrsStartSource, durationBucket: DurationBucket,
26+
errorCategory: OrsErrorCategory? = null)
27+
28+
fun trackOrsSearchOpened(hasMapFocus: Boolean)
29+
30+
fun trackOrsSearchResult(outcome: OrsOutcome, resultCount: Int,
31+
durationBucket: DurationBucket, queryLengthBucket: QueryLengthBucket,
32+
isRetry: Boolean, errorCategory: OrsErrorCategory? = null)
33+
34+
fun trackOrsSearchSelection(resultPosition: ResultPositionBucket, resultCount: Int)
2835

2936
fun trackMapLayerSelected(baseMap: String, overlay: String)
3037

@@ -40,7 +47,10 @@ object NoOpAnalyticsTracker : AnalyticsTracker {
4047
override fun trackScreen(screenName: String, screenClass: String) { /* no-op */ }
4148
override fun trackGpxLoaded(source: String, gpx: Gpx, fileName: String?) { /* no-op */ }
4249
override fun trackKmlLoaded(source: String, contentType: String, fileName: String?) { /* no-op */ }
43-
override fun trackRouteCalculated(profile: String?, waypointCount: Int) { /* no-op */ }
50+
override fun trackOrsRouteResult(outcome: OrsOutcome, profile: String, destinationCount: Int, startSource: OrsStartSource, durationBucket: DurationBucket, errorCategory: OrsErrorCategory?) { /* no-op */ }
51+
override fun trackOrsSearchOpened(hasMapFocus: Boolean) { /* no-op */ }
52+
override fun trackOrsSearchResult(outcome: OrsOutcome, resultCount: Int, durationBucket: DurationBucket, queryLengthBucket: QueryLengthBucket, isRetry: Boolean, errorCategory: OrsErrorCategory?) { /* no-op */ }
53+
override fun trackOrsSearchSelection(resultPosition: ResultPositionBucket, resultCount: Int) { /* no-op */ }
4454
override fun trackMapLayerSelected(baseMap: String, overlay: String) { /* no-op */ }
4555
override fun trackMarkersImported(importedCount: Int, skippedCount: Int) { /* no-op */ }
4656
override fun trackMarkersExported(markerCount: Int) { /* no-op */ }
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package org.nitri.opentopo.analytics
2+
3+
import com.google.gson.JsonParseException
4+
import retrofit2.HttpException
5+
import java.io.IOException
6+
import java.net.SocketTimeoutException
7+
8+
enum class OrsOutcome(val value: String) { SUCCESS("success"), EMPTY("empty"), ERROR("error") }
9+
enum class OrsStartSource(val value: String) { CURRENT_LOCATION("current_location"), MARKER_ONLY("marker_only") }
10+
enum class DurationBucket(val value: String) { UNDER_1S("under_1s"), ONE_TO_THREE_SECONDS("1_3s"), OVER_3S("over_3s") }
11+
enum class QueryLengthBucket(val value: String) { THREE_TO_FIVE("3_5"), SIX_TO_TEN("6_10"), OVER_TEN("over_10") }
12+
enum class ResultPositionBucket(val value: String) { FIRST("1"), SECOND_OR_THIRD("2_3"), FOURTH_OR_LATER("4_plus") }
13+
enum class OrsErrorCategory(val value: String) {
14+
NETWORK("network"), TIMEOUT("timeout"), AUTH("auth"), RATE_LIMIT("rate_limit"),
15+
SERVER("server"), INVALID_RESPONSE("invalid_response"), UNKNOWN("unknown")
16+
}
17+
18+
fun durationBucket(durationMillis: Long) = when {
19+
durationMillis < 1_000 -> DurationBucket.UNDER_1S
20+
durationMillis <= 3_000 -> DurationBucket.ONE_TO_THREE_SECONDS
21+
else -> DurationBucket.OVER_3S
22+
}
23+
24+
fun queryLengthBucket(length: Int) = when (length) {
25+
in 3..5 -> QueryLengthBucket.THREE_TO_FIVE
26+
in 6..10 -> QueryLengthBucket.SIX_TO_TEN
27+
else -> QueryLengthBucket.OVER_TEN
28+
}
29+
30+
/** [zeroBasedIndex] is intentionally positional; result data never enters analytics. */
31+
fun resultPositionBucket(zeroBasedIndex: Int) = when (zeroBasedIndex) {
32+
0 -> ResultPositionBucket.FIRST
33+
1, 2 -> ResultPositionBucket.SECOND_OR_THIRD
34+
else -> ResultPositionBucket.FOURTH_OR_LATER
35+
}
36+
37+
fun classifyOrsError(error: Throwable): OrsErrorCategory = when (error) {
38+
is SocketTimeoutException -> OrsErrorCategory.TIMEOUT
39+
is HttpException -> classifyOrsHttpStatus(error.code())
40+
is JsonParseException -> OrsErrorCategory.INVALID_RESPONSE
41+
is IOException -> OrsErrorCategory.NETWORK
42+
else -> OrsErrorCategory.UNKNOWN
43+
}
44+
45+
fun classifyOrsHttpStatus(status: Int): OrsErrorCategory = when (status) {
46+
401, 403 -> OrsErrorCategory.AUTH
47+
429 -> OrsErrorCategory.RATE_LIMIT
48+
in 500..599 -> OrsErrorCategory.SERVER
49+
else -> OrsErrorCategory.UNKNOWN
50+
}

‎app/src/main/java/org/nitri/opentopo/ors/Directions.kt‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import kotlinx.coroutines.launch
66
import kotlinx.coroutines.withContext
77
import org.nitri.ors.OrsClient
88
import org.nitri.ors.helper.RouteHelper
9+
import org.nitri.opentopo.analytics.OrsErrorCategory
10+
import org.nitri.opentopo.analytics.classifyOrsError
911

1012
class Directions(val client: OrsClient, private val profile: String) {
1113

@@ -19,19 +21,20 @@ class Directions(val client: OrsClient, private val profile: String) {
1921
if (gpxXml.isNotBlank()) {
2022
result.onSuccess(gpxXml)
2123
} else {
22-
result.onError("Empty response body")
24+
result.onEmpty()
2325
}
2426
}
2527
} catch (e: Exception) {
2628
withContext(Dispatchers.Main) {
27-
result.onError("Failed to fetch GPX: ${e.message}")
29+
result.onError(classifyOrsError(e))
2830
}
2931
}
3032
}
3133
}
3234

3335
interface RouteGpxResult {
3436
fun onSuccess(gpx: String)
35-
fun onError(message: String)
37+
fun onEmpty()
38+
fun onError(category: OrsErrorCategory)
3639
}
37-
}
40+
}

‎app/src/main/java/org/nitri/opentopo/view/PlaceSearchDialogFragment.kt‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import androidx.compose.foundation.layout.heightIn
1616
import androidx.compose.foundation.layout.padding
1717
import androidx.compose.foundation.layout.widthIn
1818
import androidx.compose.foundation.lazy.LazyColumn
19-
import androidx.compose.foundation.lazy.items
19+
import androidx.compose.foundation.lazy.itemsIndexed
2020
import androidx.compose.foundation.text.KeyboardActions
2121
import androidx.compose.foundation.text.KeyboardOptions
2222
import androidx.compose.material.icons.Icons
@@ -53,13 +53,17 @@ import androidx.lifecycle.setViewTreeViewModelStoreOwner
5353
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
5454
import org.nitri.opentopo.MapFragment
5555
import org.nitri.opentopo.R
56+
import org.nitri.opentopo.analytics.AnalyticsProvider
57+
import org.nitri.opentopo.analytics.resultPositionBucket
5658
import org.nitri.opentopo.model.PlaceSearchResult
5759
import org.nitri.opentopo.ui.theme.OpenTopoTheme
5860
import org.nitri.opentopo.viewmodel.PlaceSearchUiState
5961
import org.nitri.opentopo.viewmodel.PlaceSearchViewModel
6062

6163
class PlaceSearchDialogFragment : DialogFragment() {
6264

65+
private val analytics by lazy { AnalyticsProvider.get(requireContext()) }
66+
6367
private val viewModel: PlaceSearchViewModel by viewModels {
6468
val focusLon = arguments?.getDouble(ARG_FOCUS_LON)
6569
val focusLat = arguments?.getDouble(ARG_FOCUS_LAT)
@@ -72,10 +76,20 @@ class PlaceSearchDialogFragment : DialogFragment() {
7276
}
7377
},
7478
focusLon = focusLon,
75-
focusLat = focusLat
79+
focusLat = focusLat,
80+
analytics = analytics
7681
)
7782
}
7883

84+
override fun onCreate(savedInstanceState: Bundle?) {
85+
super.onCreate(savedInstanceState)
86+
if (savedInstanceState == null) {
87+
analytics.trackOrsSearchOpened(
88+
arguments?.containsKey(ARG_FOCUS_LON) == true && arguments?.containsKey(ARG_FOCUS_LAT) == true
89+
)
90+
}
91+
}
92+
7993
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
8094
val composeView = ComposeView(requireContext()).apply {
8195
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
@@ -84,7 +98,8 @@ class PlaceSearchDialogFragment : DialogFragment() {
8498
PlaceSearchDialogContent(
8599
viewModel = viewModel,
86100
isInitialLaunch = savedInstanceState == null,
87-
onResultSelected = { result ->
101+
onResultSelected = { result, index, resultCount ->
102+
analytics.trackOrsSearchSelection(resultPositionBucket(index), resultCount.coerceAtMost(10))
88103
setFragmentResult(
89104
REQUEST_KEY,
90105
Bundle().apply {
@@ -146,7 +161,7 @@ class PlaceSearchDialogFragment : DialogFragment() {
146161
private fun PlaceSearchDialogContent(
147162
viewModel: PlaceSearchViewModel,
148163
isInitialLaunch: Boolean,
149-
onResultSelected: (PlaceSearchResult) -> Unit,
164+
onResultSelected: (PlaceSearchResult, Int, Int) -> Unit,
150165
onDismiss: () -> Unit
151166
) {
152167
val query by viewModel.query.collectAsState()
@@ -249,14 +264,14 @@ private fun PlaceSearchDialogContent(
249264
.heightIn(max = 280.dp)
250265
.padding(vertical = 8.dp)
251266
) {
252-
items(
267+
itemsIndexed(
253268
items = state.results,
254-
key = { it.stableId }
255-
) { result ->
269+
key = { _, item -> item.stableId }
270+
) { index, result ->
256271
Column(
257272
modifier = Modifier
258273
.fillMaxWidth()
259-
.clickable { onResultSelected(result) }
274+
.clickable { onResultSelected(result, index, state.results.size) }
260275
.padding(vertical = 12.dp, horizontal = 4.dp)
261276
) {
262277
Text(

‎app/src/main/java/org/nitri/opentopo/viewmodel/PlaceSearchViewModel.kt‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ import kotlinx.coroutines.flow.map
1818
import kotlinx.coroutines.flow.merge
1919
import kotlinx.coroutines.flow.onEach
2020
import org.nitri.opentopo.model.PlaceSearchResult
21+
import org.nitri.opentopo.analytics.AnalyticsTracker
22+
import org.nitri.opentopo.analytics.NoOpAnalyticsTracker
23+
import org.nitri.opentopo.analytics.OrsOutcome
24+
import org.nitri.opentopo.analytics.classifyOrsError
25+
import org.nitri.opentopo.analytics.durationBucket
26+
import org.nitri.opentopo.analytics.queryLengthBucket
2127
import org.nitri.opentopo.ors.mapGeocodeFeaturesToPlaceSearchResults
2228
import org.nitri.ors.OrsClient
2329
import kotlin.coroutines.cancellation.CancellationException
@@ -34,9 +40,13 @@ sealed interface PlaceSearchUiState {
3440
class PlaceSearchViewModel(
3541
private val clientProvider: () -> OrsClient?,
3642
private val focusLon: Double?,
37-
private val focusLat: Double?
43+
private val focusLat: Double?,
44+
private val analytics: AnalyticsTracker = NoOpAnalyticsTracker,
45+
private val clockMillis: () -> Long = { System.nanoTime() / 1_000_000 }
3846
) : ViewModel() {
3947

48+
private data class SearchRequest(val query: String, val isRetry: Boolean)
49+
4050
private val _query = MutableStateFlow("")
4151
val query: StateFlow<String> = _query.asStateFlow()
4252

@@ -52,12 +62,14 @@ class PlaceSearchViewModel(
5262
.debounce { normalizedQuery ->
5363
if (normalizedQuery.length >= 3) 400L else 0L
5464
}
65+
.map { SearchRequest(it, false) }
5566

56-
val retryQuery = _retryTrigger.map { _query.value.trim() }
67+
val retryQuery = _retryTrigger.map { SearchRequest(_query.value.trim(), true) }
5768

5869
merge(debouncedQuery, retryQuery)
59-
.flatMapLatest { normalizedQuery ->
70+
.flatMapLatest { request ->
6071
flow {
72+
val normalizedQuery = request.query
6173
if (normalizedQuery.length < 3) {
6274
emit(PlaceSearchUiState.QueryTooShort)
6375
return@flow
@@ -71,6 +83,7 @@ class PlaceSearchViewModel(
7183

7284
emit(PlaceSearchUiState.Loading)
7385

86+
val startedAt = clockMillis()
7487
try {
7588
val response = client.geocodeAutocomplete(
7689
text = normalizedQuery,
@@ -79,6 +92,10 @@ class PlaceSearchViewModel(
7992
size = 10
8093
)
8194
val results = mapGeocodeFeaturesToPlaceSearchResults(response.features)
95+
val outcome = if (results.isEmpty()) OrsOutcome.EMPTY else OrsOutcome.SUCCESS
96+
analytics.trackOrsSearchResult(outcome, results.size.coerceAtMost(10),
97+
durationBucket(clockMillis() - startedAt), queryLengthBucket(normalizedQuery.length),
98+
request.isRetry)
8299
if (results.isEmpty()) {
83100
emit(PlaceSearchUiState.Empty)
84101
} else {
@@ -87,7 +104,10 @@ class PlaceSearchViewModel(
87104
} catch (e: CancellationException) {
88105
throw e
89106
} catch (e: Throwable) {
90-
emit(PlaceSearchUiState.Error(e.localizedMessage))
107+
analytics.trackOrsSearchResult(OrsOutcome.ERROR, 0,
108+
durationBucket(clockMillis() - startedAt), queryLengthBucket(normalizedQuery.length),
109+
request.isRetry, classifyOrsError(e))
110+
emit(PlaceSearchUiState.Error(null))
91111
}
92112
}
93113
}
@@ -108,11 +128,12 @@ class PlaceSearchViewModel(
108128
class Factory(
109129
private val clientProvider: () -> OrsClient?,
110130
private val focusLon: Double?,
111-
private val focusLat: Double?
131+
private val focusLat: Double?,
132+
private val analytics: AnalyticsTracker = NoOpAnalyticsTracker
112133
) : ViewModelProvider.Factory {
113134
@Suppress("UNCHECKED_CAST")
114135
override fun <T : ViewModel> create(modelClass: Class<T>): T {
115-
return PlaceSearchViewModel(clientProvider, focusLon, focusLat) as T
136+
return PlaceSearchViewModel(clientProvider, focusLon, focusLat, analytics) as T
116137
}
117138
}
118139
}

‎app/src/main/res/values/strings.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ This cannot be undone.</string>
185185
<string name="search_min_chars">Enter at least 3 characters</string>
186186
<string name="no_matching_places">No matching places found</string>
187187
<string name="unable_to_search_places">Unable to search places</string>
188+
<string name="unable_to_calculate_route">Unable to calculate route</string>
188189
<string name="retry">Retry</string>
189190

190191
</resources>

0 commit comments

Comments
 (0)