Skip to content

Commit 96a1c96

Browse files
committed
feat: add periodic device activity reporter
Logs active-device counts grouped by client version every 5 minutes (1h and 24h windows). Active = lastSeen within window OR currently connected via WebSocket. Adds activeDeviceKeys() snapshot on ConnectionRegistry to surface live connections that haven't recently pinged lastSeen.
1 parent 2e831e3 commit 96a1c96

5 files changed

Lines changed: 304 additions & 0 deletions

File tree

src/main/kotlin/eu/darken/octi/server/App.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import eu.darken.octi.server.common.debug.logging.Logging.Priority.*
99
import eu.darken.octi.server.common.debug.logging.log
1010
import eu.darken.octi.server.common.debug.logging.logTag
1111
import eu.darken.octi.server.common.debug.DebugFlagMonitor
12+
import eu.darken.octi.server.device.DeviceActivityReporter
1213
import eu.darken.octi.server.module.StartupRecoveryService
1314
import eu.darken.octi.server.module.UploadSessionRepo
1415
import java.nio.file.Path
@@ -26,6 +27,7 @@ class App @Inject constructor(
2627
private val sessionRepo: UploadSessionRepo,
2728
private val diskSpaceProbe: DiskSpaceProbe,
2829
@Suppress("unused") private val debugFlagMonitor: DebugFlagMonitor,
30+
@Suppress("unused") private val deviceActivityReporter: DeviceActivityReporter,
2931
) {
3032

3133
fun launch() {
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package eu.darken.octi.server.device
2+
3+
import eu.darken.octi.server.common.AppScope
4+
import eu.darken.octi.server.common.debug.logging.Logging.Priority.INFO
5+
import eu.darken.octi.server.common.debug.logging.log
6+
import eu.darken.octi.server.common.debug.logging.logTag
7+
import eu.darken.octi.server.common.launchPeriodicJob
8+
import eu.darken.octi.server.ws.ConnectionRegistry
9+
import java.time.Duration
10+
import java.time.Instant
11+
import java.util.Locale
12+
import javax.inject.Inject
13+
import javax.inject.Singleton
14+
15+
@Singleton
16+
class DeviceActivityReporter @Inject constructor(
17+
appScope: AppScope,
18+
private val deviceRepo: DeviceRepo,
19+
private val connectionRegistry: ConnectionRegistry,
20+
) {
21+
22+
data class Report(
23+
val oneHour: WindowStats,
24+
val twentyFourHours: WindowStats,
25+
)
26+
27+
data class WindowStats(
28+
val label: String,
29+
val total: Int,
30+
val versions: List<VersionStats>,
31+
)
32+
33+
data class VersionStats(
34+
val version: String,
35+
val count: Int,
36+
val percent: Double,
37+
)
38+
39+
init {
40+
appScope.launchPeriodicJob(
41+
tag = TAG,
42+
interval = REPORT_INTERVAL,
43+
initialDelay = REPORT_INTERVAL,
44+
onErrorMessage = "Device activity report failed",
45+
) {
46+
logReport()
47+
}
48+
}
49+
50+
internal fun logReport(now: Instant = Instant.now()) {
51+
val report = buildReport(
52+
devices = deviceRepo.allDevices(),
53+
activeDeviceKeys = connectionRegistry.activeDeviceKeys(),
54+
now = now,
55+
)
56+
log(TAG, INFO) { formatReport(report) }
57+
}
58+
59+
companion object {
60+
private val REPORT_INTERVAL: Duration = Duration.ofMinutes(5)
61+
private val ONE_HOUR: Duration = Duration.ofHours(1)
62+
private val TWENTY_FOUR_HOURS: Duration = Duration.ofHours(24)
63+
private const val UNKNOWN_VERSION = "<unknown>"
64+
private const val MAX_VERSION_DISPLAY_LENGTH = 80
65+
private val CONTROL_CHARS = Regex("[\\p{Cntrl}]+")
66+
private val TAG = logTag("Device", "Activity")
67+
68+
internal fun buildReport(
69+
devices: Collection<Device>,
70+
activeDeviceKeys: Set<DeviceKey>,
71+
now: Instant = Instant.now(),
72+
): Report = Report(
73+
oneHour = buildWindowStats("1h", ONE_HOUR, devices, activeDeviceKeys, now),
74+
twentyFourHours = buildWindowStats("24h", TWENTY_FOUR_HOURS, devices, activeDeviceKeys, now),
75+
)
76+
77+
internal fun formatReport(report: Report): String {
78+
return "device-stats: ${formatWindow(report.oneHour)}; ${formatWindow(report.twentyFourHours)}"
79+
}
80+
81+
internal fun sanitizeVersionForLog(raw: String?): String {
82+
val sanitized = raw
83+
?.replace(CONTROL_CHARS, " ")
84+
?.trim()
85+
?.ifBlank { null }
86+
?: return UNKNOWN_VERSION
87+
88+
return if (sanitized.length <= MAX_VERSION_DISPLAY_LENGTH) {
89+
sanitized
90+
} else {
91+
sanitized.take(MAX_VERSION_DISPLAY_LENGTH - 3) + "..."
92+
}
93+
}
94+
95+
private fun buildWindowStats(
96+
label: String,
97+
window: Duration,
98+
devices: Collection<Device>,
99+
activeDeviceKeys: Set<DeviceKey>,
100+
now: Instant,
101+
): WindowStats {
102+
val cutoff = now.minus(window)
103+
val activeDevices = devices.filter { device ->
104+
!device.lastSeen.isBefore(cutoff) || device.key in activeDeviceKeys
105+
}
106+
107+
val versionCounts = activeDevices
108+
.groupingBy { sanitizeVersionForLog(it.version) }
109+
.eachCount()
110+
111+
val total = activeDevices.size
112+
val versions = versionCounts.entries
113+
.map { (version, count) ->
114+
VersionStats(
115+
version = version,
116+
count = count,
117+
percent = if (total == 0) 0.0 else count * 100.0 / total,
118+
)
119+
}
120+
.sortedWith(compareByDescending<VersionStats> { it.count }.thenBy { it.version })
121+
122+
return WindowStats(
123+
label = label,
124+
total = total,
125+
versions = versions,
126+
)
127+
}
128+
129+
private fun formatWindow(stats: WindowStats): String {
130+
val versions = stats.versions.joinToString(prefix = "[", postfix = "]") {
131+
"${it.version}=${it.count} (${formatPercent(it.percent)}%)"
132+
}
133+
return "${stats.label} total=${stats.total} versions=$versions"
134+
}
135+
136+
private fun formatPercent(percent: Double): String = String.format(Locale.US, "%.1f", percent)
137+
}
138+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ class ConnectionRegistry(
128128
return sessions.values.filter { it.accountId == accountId && it.deviceId != excludeDevice }
129129
}
130130

131+
fun activeDeviceKeys(): Set<DeviceKey> = sessions.keys.toSet()
132+
131133
fun stats(): Stats = Stats(
132134
totalDevices = sessions.size,
133135
totalAccounts = sessions.values.map { it.accountId }.distinct().size,
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package eu.darken.octi.server.device
2+
3+
import io.kotest.matchers.shouldBe
4+
import org.junit.jupiter.api.Test
5+
import java.nio.file.Path
6+
import java.time.Duration
7+
import java.time.Instant
8+
import java.util.UUID
9+
10+
class DeviceActivityReporterTest {
11+
12+
private val now: Instant = Instant.parse("2026-04-29T12:00:00Z")
13+
14+
@Test
15+
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)))
20+
21+
val report = DeviceActivityReporter.buildReport(
22+
devices = listOf(oneHour, twentyFourHours, connected, inactive),
23+
activeDeviceKeys = setOf(connected.key),
24+
now = now,
25+
)
26+
27+
report.oneHour.total shouldBe 2
28+
report.oneHour.versionCounts() shouldBe mapOf(
29+
"octi/1.0.0" to 1,
30+
"octi/3.0.0" to 1,
31+
)
32+
33+
report.twentyFourHours.total shouldBe 3
34+
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,
38+
)
39+
}
40+
41+
@Test
42+
fun `report formats counts and percentages sorted by count`() {
43+
val report = DeviceActivityReporter.buildReport(
44+
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),
48+
),
49+
activeDeviceKeys = emptySet(),
50+
now = now,
51+
)
52+
53+
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%)]"
56+
}
57+
58+
@Test
59+
fun `unknown versions are grouped and empty windows are safe`() {
60+
val empty = DeviceActivityReporter.buildReport(
61+
devices = emptyList(),
62+
activeDeviceKeys = emptySet(),
63+
now = now,
64+
)
65+
empty.oneHour.total shouldBe 0
66+
empty.oneHour.versions shouldBe emptyList()
67+
68+
val report = DeviceActivityReporter.buildReport(
69+
devices = listOf(
70+
device(version = null, lastSeen = now),
71+
device(version = " ", lastSeen = now),
72+
),
73+
activeDeviceKeys = emptySet(),
74+
now = now,
75+
)
76+
77+
report.oneHour.versions shouldBe listOf(
78+
DeviceActivityReporter.VersionStats(
79+
version = "<unknown>",
80+
count = 2,
81+
percent = 100.0,
82+
)
83+
)
84+
}
85+
86+
@Test
87+
fun `sanitized-equivalent versions are grouped together`() {
88+
val report = DeviceActivityReporter.buildReport(
89+
devices = listOf(
90+
device(version = "octi/1.0.0", lastSeen = now),
91+
device(version = " octi/1.0.0 ", lastSeen = now),
92+
device(version = null, lastSeen = now),
93+
device(version = "\n\t", lastSeen = now),
94+
),
95+
activeDeviceKeys = emptySet(),
96+
now = now,
97+
)
98+
99+
report.oneHour.versions shouldBe listOf(
100+
DeviceActivityReporter.VersionStats(
101+
version = "<unknown>",
102+
count = 2,
103+
percent = 50.0,
104+
),
105+
DeviceActivityReporter.VersionStats(
106+
version = "octi/1.0.0",
107+
count = 2,
108+
percent = 50.0,
109+
),
110+
)
111+
}
112+
113+
@Test
114+
fun `version display is sanitized for logs`() {
115+
DeviceActivityReporter.sanitizeVersionForLog(" octi\n1\t ") shouldBe "octi 1"
116+
117+
val longVersion = "v" + "a".repeat(100)
118+
val sanitized = DeviceActivityReporter.sanitizeVersionForLog(longVersion)
119+
120+
sanitized.length shouldBe 80
121+
sanitized.endsWith("...") shouldBe true
122+
}
123+
124+
private fun DeviceActivityReporter.WindowStats.versionCounts(): Map<String, Int> {
125+
return versions.associate { it.version to it.count }
126+
}
127+
128+
private fun device(
129+
version: String?,
130+
lastSeen: Instant,
131+
accountId: UUID = UUID.randomUUID(),
132+
deviceId: UUID = UUID.randomUUID(),
133+
): Device {
134+
return Device(
135+
data = Device.Data(
136+
id = deviceId,
137+
password = "test-password",
138+
version = version,
139+
addedAt = lastSeen,
140+
lastSeen = lastSeen,
141+
),
142+
path = Path.of("/tmp/$deviceId"),
143+
accountId = accountId,
144+
)
145+
}
146+
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package eu.darken.octi.server.ws
22

33
import eu.darken.octi.server.common.AppScope
4+
import eu.darken.octi.server.device.DeviceKey
45
import kotlinx.coroutines.DelicateCoroutinesApi
56
import kotlinx.coroutines.runBlocking
67
import io.kotest.matchers.collections.shouldBeEmpty
@@ -116,6 +117,21 @@ class ConnectionRegistryTest {
116117
registry.stats() shouldBe ConnectionRegistry.Stats(totalDevices = 3, totalAccounts = 2)
117118
}
118119

120+
@Test
121+
fun `activeDeviceKeys returns connected device snapshot`() = runBlocking {
122+
val session = registerDevice(device1, accountA)
123+
registerDevice(device2, accountA)
124+
125+
registry.activeDeviceKeys() shouldBe setOf(
126+
DeviceKey(accountA, device1),
127+
DeviceKey(accountA, device2),
128+
)
129+
130+
registry.unregister(session)
131+
132+
registry.activeDeviceKeys() shouldBe setOf(DeviceKey(accountA, device2))
133+
}
134+
119135
@Nested
120136
inner class `composite key` {
121137

0 commit comments

Comments
 (0)