Skip to content

Commit ee97e62

Browse files
committed
Add build flavor split to device stats
1 parent 63b2bf5 commit ee97e62

5 files changed

Lines changed: 150 additions & 31 deletions

File tree

.claude/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
scheduled_tasks.lock

src/main/kotlin/eu/darken/octi/server/device/DeviceActivityReporter.kt

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class DeviceActivityReporter @Inject constructor(
2828
val label: String,
2929
val total: Int,
3030
val versions: List<VersionStats>,
31+
val buildFlavors: List<BuildFlavorStats>,
3132
)
3233

3334
data class VersionStats(
@@ -36,6 +37,12 @@ class DeviceActivityReporter @Inject constructor(
3637
val percent: Double,
3738
)
3839

40+
data class BuildFlavorStats(
41+
val flavor: String,
42+
val count: Int,
43+
val percent: Double,
44+
)
45+
3946
init {
4047
appScope.launchPeriodicJob(
4148
tag = TAG,
@@ -61,8 +68,11 @@ class DeviceActivityReporter @Inject constructor(
6168
private val ONE_HOUR: Duration = Duration.ofHours(1)
6269
private val TWENTY_FOUR_HOURS: Duration = Duration.ofHours(24)
6370
private const val UNKNOWN_VERSION = "<unknown>"
71+
private const val UNKNOWN_BUILD_FLAVOR = "<unknown>"
6472
private const val MAX_VERSION_DISPLAY_LENGTH = 80
6573
private val CONTROL_CHARS = Regex("[\\p{Cntrl}]+")
74+
private val BUILD_FLAVOR_TOKEN_SEPARATOR = Regex("[^A-Za-z0-9]+")
75+
private val BUILD_FLAVOR_ORDER = listOf("FOSS", "GPLAY", UNKNOWN_BUILD_FLAVOR)
6676
private val TAG = logTag("Device", "Activity")
6777

6878
internal fun buildReport(
@@ -75,7 +85,11 @@ class DeviceActivityReporter @Inject constructor(
7585
)
7686

7787
internal fun formatReport(report: Report): String {
78-
return "device-stats: ${formatWindow(report.oneHour)}; ${formatWindow(report.twentyFourHours)}"
88+
return buildString {
89+
appendLine("device-stats:")
90+
appendWindow(report.oneHour)
91+
appendWindow(report.twentyFourHours)
92+
}.trimEnd()
7993
}
8094

8195
internal fun sanitizeVersionForLog(raw: String?): String {
@@ -92,6 +106,22 @@ class DeviceActivityReporter @Inject constructor(
92106
}
93107
}
94108

109+
internal fun buildFlavorForLog(raw: String?): String {
110+
val sanitized = raw
111+
?.replace(CONTROL_CHARS, " ")
112+
?.trim()
113+
?.ifBlank { null }
114+
?: return UNKNOWN_BUILD_FLAVOR
115+
116+
val tokens = sanitized.split(BUILD_FLAVOR_TOKEN_SEPARATOR)
117+
118+
return when {
119+
tokens.any { it.equals("FOSS", ignoreCase = true) } -> "FOSS"
120+
tokens.any { it.equals("GPLAY", ignoreCase = true) || it.equals("GPLATE", ignoreCase = true) } -> "GPLAY"
121+
else -> UNKNOWN_BUILD_FLAVOR
122+
}
123+
}
124+
95125
private fun buildWindowStats(
96126
label: String,
97127
window: Duration,
@@ -108,6 +138,10 @@ class DeviceActivityReporter @Inject constructor(
108138
.groupingBy { sanitizeVersionForLog(it.version) }
109139
.eachCount()
110140

141+
val buildFlavorCounts = activeDevices
142+
.groupingBy { buildFlavorForLog(it.version) }
143+
.eachCount()
144+
111145
val total = activeDevices.size
112146
val versions = versionCounts.entries
113147
.map { (version, count) ->
@@ -119,18 +153,43 @@ class DeviceActivityReporter @Inject constructor(
119153
}
120154
.sortedWith(compareByDescending<VersionStats> { it.count }.thenBy { it.version })
121155

156+
val buildFlavors = buildFlavorCounts.entries
157+
.map { (flavor, count) ->
158+
BuildFlavorStats(
159+
flavor = flavor,
160+
count = count,
161+
percent = if (total == 0) 0.0 else count * 100.0 / total,
162+
)
163+
}
164+
.sortedBy { BUILD_FLAVOR_ORDER.indexOf(it.flavor).takeIf { index -> index >= 0 } ?: Int.MAX_VALUE }
165+
122166
return WindowStats(
123167
label = label,
124168
total = total,
125169
versions = versions,
170+
buildFlavors = buildFlavors,
171+
)
172+
}
173+
174+
private fun StringBuilder.appendWindow(stats: WindowStats) {
175+
appendLine(" ${stats.label}: total=${stats.total}")
176+
appendStatsSection(
177+
label = "flavors",
178+
entries = stats.buildFlavors.map { "${it.flavor}=${it.count} (${formatPercent(it.percent)}%)" },
179+
)
180+
appendStatsSection(
181+
label = "versions",
182+
entries = stats.versions.map { "${it.version}=${it.count} (${formatPercent(it.percent)}%)" },
126183
)
127184
}
128185

129-
private fun formatWindow(stats: WindowStats): String {
130-
val versions = stats.versions.joinToString(prefix = "[", postfix = "]") {
131-
"${it.version}=${it.count} (${formatPercent(it.percent)}%)"
186+
private fun StringBuilder.appendStatsSection(label: String, entries: List<String>) {
187+
appendLine(" $label:")
188+
if (entries.isEmpty()) {
189+
appendLine(" <none>")
190+
} else {
191+
entries.forEach { appendLine(" $it") }
132192
}
133-
return "${stats.label} total=${stats.total} versions=$versions"
134193
}
135194

136195
private fun formatPercent(percent: Double): String = String.format(Locale.US, "%.1f", percent)

src/main/kotlin/eu/darken/octi/server/ws/SyncNotificationStats.kt

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,24 @@ internal class SyncNotificationStats {
1919
fun format(window: Duration): String {
2020
val modules = moduleCounts.entries
2121
.sortedWith(compareByDescending<Map.Entry<String, Long>> { it.value }.thenBy { it.key })
22-
.joinToString(prefix = "[", postfix = "]") { "${it.key}=${it.value}" }
2322

24-
return "sync-stats: ${window.toCompactString()} batches=$batches events=$events " +
25-
"deliveredPayloads=$deliveredPayloads deliveredEvents=$deliveredEvents " +
26-
"skippedSelfPeers=$skippedSelfPeers noPeers=$noPeers " +
27-
"closedSessions=$closedSessions bufferFullDrops=$bufferFullDrops failures=$failures " +
28-
"modules=$modules"
23+
return buildString {
24+
appendLine("sync-stats: ${window.toCompactString()}")
25+
appendLine(
26+
" traffic: batches=$batches events=$events " +
27+
"deliveredPayloads=$deliveredPayloads deliveredEvents=$deliveredEvents"
28+
)
29+
appendLine(
30+
" outcomes: skippedSelfPeers=$skippedSelfPeers noPeers=$noPeers " +
31+
"closedSessions=$closedSessions bufferFullDrops=$bufferFullDrops failures=$failures"
32+
)
33+
appendLine(" modules:")
34+
if (modules.isEmpty()) {
35+
appendLine(" <none>")
36+
} else {
37+
modules.forEach { appendLine(" ${it.key}=${it.value}") }
38+
}
39+
}.trimEnd()
2940
}
3041
}
3142

src/test/kotlin/eu/darken/octi/server/device/DeviceActivityReporterTest.kt

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ class DeviceActivityReporterTest {
1313

1414
@Test
1515
fun `report counts lastSeen windows and currently connected devices`() {
16-
val oneHour = device(version = "octi/1.0.0", lastSeen = now.minus(Duration.ofMinutes(10)))
17-
val twentyFourHours = device(version = "octi/2.0.0", lastSeen = now.minus(Duration.ofHours(2)))
18-
val connected = device(version = "octi/3.0.0", lastSeen = now.minus(Duration.ofHours(30)))
19-
val inactive = device(version = "octi/4.0.0", lastSeen = now.minus(Duration.ofHours(30)))
16+
val oneHour = device(version = "octi/1.0.0/FOSS", lastSeen = now.minus(Duration.ofMinutes(10)))
17+
val twentyFourHours = device(version = "octi/2.0.0/GPLAY", lastSeen = now.minus(Duration.ofHours(2)))
18+
val connected = device(version = "octi/3.0.0/GPLAY", lastSeen = now.minus(Duration.ofHours(30)))
19+
val inactive = device(version = "octi/4.0.0/FOSS", lastSeen = now.minus(Duration.ofHours(30)))
2020

2121
val report = DeviceActivityReporter.buildReport(
2222
devices = listOf(oneHour, twentyFourHours, connected, inactive),
@@ -26,33 +26,56 @@ class DeviceActivityReporterTest {
2626

2727
report.oneHour.total shouldBe 2
2828
report.oneHour.versionCounts() shouldBe mapOf(
29-
"octi/1.0.0" to 1,
30-
"octi/3.0.0" to 1,
29+
"octi/1.0.0/FOSS" to 1,
30+
"octi/3.0.0/GPLAY" to 1,
31+
)
32+
report.oneHour.buildFlavorCounts() shouldBe mapOf(
33+
"FOSS" to 1,
34+
"GPLAY" to 1,
3135
)
3236

3337
report.twentyFourHours.total shouldBe 3
3438
report.twentyFourHours.versionCounts() shouldBe mapOf(
35-
"octi/1.0.0" to 1,
36-
"octi/2.0.0" to 1,
37-
"octi/3.0.0" to 1,
39+
"octi/1.0.0/FOSS" to 1,
40+
"octi/2.0.0/GPLAY" to 1,
41+
"octi/3.0.0/GPLAY" to 1,
42+
)
43+
report.twentyFourHours.buildFlavorCounts() shouldBe mapOf(
44+
"FOSS" to 1,
45+
"GPLAY" to 2,
3846
)
3947
}
4048

4149
@Test
4250
fun `report formats counts and percentages sorted by count`() {
4351
val report = DeviceActivityReporter.buildReport(
4452
devices = listOf(
45-
device(version = "octi/1.0.0", lastSeen = now),
46-
device(version = "octi/1.0.0", lastSeen = now),
47-
device(version = "octi/2.0.0", lastSeen = now),
53+
device(version = "octi/1.0.0/FOSS", lastSeen = now),
54+
device(version = "octi/1.0.0/FOSS", lastSeen = now),
55+
device(version = "octi/2.0.0/GPLAY", lastSeen = now),
4856
),
4957
activeDeviceKeys = emptySet(),
5058
now = now,
5159
)
5260

5361
DeviceActivityReporter.formatReport(report) shouldBe
54-
"device-stats: 1h total=3 versions=[octi/1.0.0=2 (66.7%), octi/2.0.0=1 (33.3%)]; " +
55-
"24h total=3 versions=[octi/1.0.0=2 (66.7%), octi/2.0.0=1 (33.3%)]"
62+
"""
63+
device-stats:
64+
1h: total=3
65+
flavors:
66+
FOSS=2 (66.7%)
67+
GPLAY=1 (33.3%)
68+
versions:
69+
octi/1.0.0/FOSS=2 (66.7%)
70+
octi/2.0.0/GPLAY=1 (33.3%)
71+
24h: total=3
72+
flavors:
73+
FOSS=2 (66.7%)
74+
GPLAY=1 (33.3%)
75+
versions:
76+
octi/1.0.0/FOSS=2 (66.7%)
77+
octi/2.0.0/GPLAY=1 (33.3%)
78+
""".trimIndent()
5679
}
5780

5881
@Test
@@ -64,6 +87,7 @@ class DeviceActivityReporterTest {
6487
)
6588
empty.oneHour.total shouldBe 0
6689
empty.oneHour.versions shouldBe emptyList()
90+
empty.oneHour.buildFlavors shouldBe emptyList()
6791

6892
val report = DeviceActivityReporter.buildReport(
6993
devices = listOf(
@@ -81,6 +105,13 @@ class DeviceActivityReporterTest {
81105
percent = 100.0,
82106
)
83107
)
108+
report.oneHour.buildFlavors shouldBe listOf(
109+
DeviceActivityReporter.BuildFlavorStats(
110+
flavor = "<unknown>",
111+
count = 2,
112+
percent = 100.0,
113+
)
114+
)
84115
}
85116

86117
@Test
@@ -121,10 +152,23 @@ class DeviceActivityReporterTest {
121152
sanitized.endsWith("...") shouldBe true
122153
}
123154

155+
@Test
156+
fun `build flavor is parsed from user-agent style versions`() {
157+
DeviceActivityReporter.buildFlavorForLog("octi/1.2.3/FOSS") shouldBe "FOSS"
158+
DeviceActivityReporter.buildFlavorForLog("octi/1.2.3/gplay/dev-a1b2c3d") shouldBe "GPLAY"
159+
DeviceActivityReporter.buildFlavorForLog("octi/1.2.3/GPLATE") shouldBe "GPLAY"
160+
DeviceActivityReporter.buildFlavorForLog("1.2.3") shouldBe "<unknown>"
161+
DeviceActivityReporter.buildFlavorForLog(null) shouldBe "<unknown>"
162+
}
163+
124164
private fun DeviceActivityReporter.WindowStats.versionCounts(): Map<String, Int> {
125165
return versions.associate { it.version to it.count }
126166
}
127167

168+
private fun DeviceActivityReporter.WindowStats.buildFlavorCounts(): Map<String, Int> {
169+
return buildFlavors.associate { it.flavor to it.count }
170+
}
171+
128172
private fun device(
129173
version: String?,
130174
lastSeen: Instant,

src/test/kotlin/eu/darken/octi/server/ws/SyncNotificationStatsTest.kt

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,16 @@ class SyncNotificationStatsTest {
8585

8686
val line = snapshot.format(Duration.ofMinutes(1))
8787

88-
line shouldBe "sync-stats: 1m batches=2 events=8 " +
89-
"deliveredPayloads=3 deliveredEvents=6 skippedSelfPeers=1 noPeers=1 " +
90-
"closedSessions=1 bufferFullDrops=1 failures=0 " +
91-
"modules=[eu.darken.octi.module.core.clipboard=5, eu.darken.octi.module.core.power=2, " +
92-
"eu.darken.octi.module.core.meta=1]"
93-
line shouldContain "modules=[eu.darken.octi.module.core.clipboard=5"
88+
line shouldBe """
89+
sync-stats: 1m
90+
traffic: batches=2 events=8 deliveredPayloads=3 deliveredEvents=6
91+
outcomes: skippedSelfPeers=1 noPeers=1 closedSessions=1 bufferFullDrops=1 failures=0
92+
modules:
93+
eu.darken.octi.module.core.clipboard=5
94+
eu.darken.octi.module.core.power=2
95+
eu.darken.octi.module.core.meta=1
96+
""".trimIndent()
97+
line shouldContain "\n eu.darken.octi.module.core.clipboard=5"
9498
}
9599

96100
private fun event(moduleId: String): SyncNotifier.EventPayload.Event.ModuleChanged =

0 commit comments

Comments
 (0)