Skip to content

Commit 8517044

Browse files
feat: Add HTTP API to export captured network logs (#526)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 8802a49 commit 8517044

13 files changed

Lines changed: 313 additions & 3 deletions

File tree

FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/network/datasource/NetworkLocalDataSource.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package io.github.openflocon.data.core.network.datasource
33
import androidx.paging.PagingData
44
import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel
55
import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel
6+
import io.github.openflocon.domain.network.models.NetworkCallWithDeviceId
67
import io.github.openflocon.domain.network.models.NetworkFilterDomainModel
78
import io.github.openflocon.domain.network.models.NetworkSortDomainModel
89
import kotlinx.coroutines.flow.Flow
@@ -54,5 +55,11 @@ interface NetworkLocalDataSource {
5455

5556
suspend fun deleteRequestOnDifferentSession(deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel)
5657

58+
suspend fun getAllRequests(
59+
deviceId: String? = null,
60+
startTimestamp: Long? = null,
61+
endTimestamp: Long? = null,
62+
): List<NetworkCallWithDeviceId>
63+
5764
suspend fun clear()
5865
}

FloconDesktop/data/core/src/commonMain/kotlin/io/github/openflocon/data/core/network/repository/NetworkRepositoryImpl.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import io.github.openflocon.domain.messages.repository.MessagesReceiverRepositor
1515
import io.github.openflocon.domain.network.models.BadQualityConfigDomainModel
1616
import io.github.openflocon.domain.network.models.BadQualityConfigId
1717
import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel
18+
import io.github.openflocon.domain.network.models.NetworkCallWithDeviceId
1819
import io.github.openflocon.domain.network.models.FloconNetworkResponseOnlyDomainModel
1920
import io.github.openflocon.domain.network.models.MockNetworkDomainModel
2021
import io.github.openflocon.domain.network.models.NetworkFilterDomainModel
@@ -98,6 +99,15 @@ class NetworkRepositoryImpl(
9899
)
99100
}
100101

102+
override suspend fun getAllNetworkCalls(
103+
deviceId: String?,
104+
startTimestamp: Long?,
105+
endTimestamp: Long?,
106+
): List<NetworkCallWithDeviceId> =
107+
withContext(dispatcherProvider.data) {
108+
networkLocalDataSource.getAllRequests(deviceId, startTimestamp, endTimestamp)
109+
}
110+
101111
override suspend fun getRequests(
102112
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
103113
ids: List<String>

FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/dao/FloconNetworkDao.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,34 @@ interface FloconNetworkDao {
6868
callId: String,
6969
): FloconNetworkCallEntity?
7070

71+
@Query(
72+
"""
73+
SELECT * FROM FloconNetworkCallEntity
74+
WHERE (:startTimestamp IS NULL OR request_startTime >= :startTimestamp)
75+
AND (:endTimestamp IS NULL OR request_startTime <= :endTimestamp)
76+
ORDER BY request_startTime ASC
77+
""",
78+
)
79+
suspend fun getAllRequests(
80+
startTimestamp: Long? = null,
81+
endTimestamp: Long? = null,
82+
): List<FloconNetworkCallEntity>
83+
84+
@Query(
85+
"""
86+
SELECT * FROM FloconNetworkCallEntity
87+
WHERE deviceId = :deviceId
88+
AND (:startTimestamp IS NULL OR request_startTime >= :startTimestamp)
89+
AND (:endTimestamp IS NULL OR request_startTime <= :endTimestamp)
90+
ORDER BY request_startTime ASC
91+
""",
92+
)
93+
suspend fun getAllRequestsByDevice(
94+
deviceId: String,
95+
startTimestamp: Long? = null,
96+
endTimestamp: Long? = null,
97+
): List<FloconNetworkCallEntity>
98+
7199
@Query("DELETE FROM FloconNetworkCallEntity")
72100
suspend fun clearAll()
73101

FloconDesktop/data/local/src/commonMain/kotlin/io/github/openflocon/data/local/network/datasource/NetworkLocalDataSourceRoom.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import io.github.openflocon.data.local.network.mapper.toEntity
1212
import io.github.openflocon.data.local.network.models.FloconNetworkCallEntity
1313
import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel
1414
import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel
15+
import io.github.openflocon.domain.network.models.NetworkCallWithDeviceId
1516
import io.github.openflocon.domain.network.models.NetworkFilterDomainModel
1617
import io.github.openflocon.domain.network.models.NetworkSortDomainModel
1718
import io.github.openflocon.domain.network.models.NetworkTextFilterColumns
@@ -260,6 +261,23 @@ class NetworkLocalDataSourceRoom(
260261
)
261262
}
262263

264+
override suspend fun getAllRequests(
265+
deviceId: String?,
266+
startTimestamp: Long?,
267+
endTimestamp: Long?,
268+
): List<NetworkCallWithDeviceId> {
269+
val entities = if (deviceId != null) {
270+
floconNetworkDao.getAllRequestsByDevice(deviceId, startTimestamp, endTimestamp)
271+
} else {
272+
floconNetworkDao.getAllRequests(startTimestamp, endTimestamp)
273+
}
274+
return entities.mapNotNull { entity ->
275+
entity.toDomainModel()?.let { domain ->
276+
NetworkCallWithDeviceId(deviceId = entity.deviceId, call = domain)
277+
}
278+
}
279+
}
280+
263281
override suspend fun clear() {
264282
floconNetworkDao.clearAll()
265283
}

FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/messages/DI.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import org.koin.dsl.module
1111
internal val messagesModule = module {
1212
single<Server> {
1313
getServer(
14-
json = get()
14+
json = get(),
15+
networkRepository = lazy { get() },
1516
)
1617
}
1718
singleOf(::MessageRemoteDataSourceImpl) bind MessageRemoteDataSource::class
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.flocon.data.remote.models
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
internal data class NetworkLogsExportResponse(
7+
val data: List<NetworkCallExport>,
8+
val metadata: ExportMetadata,
9+
)
10+
11+
@Serializable
12+
internal data class ExportMetadata(
13+
val exportedAt: Long,
14+
val totalItems: Int,
15+
val filteredBy: FilterCriteria?,
16+
)
17+
18+
@Serializable
19+
internal data class FilterCriteria(
20+
val deviceId: String? = null,
21+
val startTimestamp: Long? = null,
22+
val endTimestamp: Long? = null,
23+
)
24+
25+
@Serializable
26+
internal data class NetworkCallExport(
27+
val callId: String,
28+
val method: String,
29+
val url: String,
30+
val startTime: Long,
31+
val startTimeFormatted: String,
32+
val statusCode: Int? = null,
33+
val durationMs: Double? = null,
34+
val requestHeaders: Map<String, String>,
35+
val responseHeaders: Map<String, String>? = null,
36+
val requestBody: String? = null,
37+
val responseBody: String? = null,
38+
val contentType: String? = null,
39+
val deviceId: String,
40+
val appInstance: Long,
41+
)

FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/server/Server.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.flocon.data.remote.models.FloconIncomingMessageDataModel
55
import com.flocon.data.remote.models.FloconOutgoingMessageDataModel
66
import io.github.openflocon.domain.Constant
77
import io.github.openflocon.domain.messages.models.FloconReceivedFileDomainModel
8+
import io.github.openflocon.domain.network.repository.NetworkRepository
89
import kotlinx.coroutines.flow.Flow
910
import kotlinx.serialization.json.Json
1011
import kotlin.uuid.ExperimentalUuidApi
@@ -31,4 +32,7 @@ interface Server {
3132
@OptIn(ExperimentalUuidApi::class)
3233
fun newRequestId(): String = Uuid.random().toString()
3334

34-
expect fun getServer(json: Json): Server
35+
expect fun getServer(
36+
json: Json,
37+
networkRepository: Lazy<NetworkRepository>,
38+
): Server
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package com.flocon.data.remote.server
2+
3+
import co.touchlab.kermit.Logger
4+
import com.flocon.data.remote.models.ExportMetadata
5+
import com.flocon.data.remote.models.FilterCriteria
6+
import com.flocon.data.remote.models.NetworkCallExport
7+
import com.flocon.data.remote.models.NetworkLogsExportResponse
8+
import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel
9+
import io.github.openflocon.domain.network.models.NetworkCallWithDeviceId
10+
import io.ktor.http.ContentType
11+
import io.ktor.http.HttpStatusCode
12+
import io.ktor.server.application.ApplicationCall
13+
import io.ktor.server.response.respondText
14+
import io.ktor.server.routing.Route
15+
import io.ktor.server.routing.get
16+
import kotlinx.serialization.builtins.MapSerializer
17+
import kotlinx.serialization.json.Json
18+
import kotlinx.serialization.serializer
19+
20+
fun Route.networkExportRoutes(
21+
json: Json,
22+
// Filtering is done at DB level — timestamps passed through to the query
23+
getNetworkCalls: suspend (deviceId: String?, startTimestamp: Long?, endTimestamp: Long?) -> List<NetworkCallWithDeviceId>,
24+
) {
25+
// 1. GET /api/network-logs — all calls from all devices
26+
get("/api/network-logs") {
27+
val calls = getNetworkCalls(null, null, null)
28+
call.respondJson(json, calls, deviceId = null, startTimestamp = null, endTimestamp = null)
29+
}
30+
31+
// 2. GET /api/network-logs/{deviceId} — all calls for a specific device
32+
get("/api/network-logs/{deviceId}") {
33+
val deviceId = call.parameters["deviceId"]
34+
val calls = getNetworkCalls(deviceId, null, null)
35+
call.respondJson(json, calls, deviceId = deviceId, startTimestamp = null, endTimestamp = null)
36+
}
37+
38+
// 3. GET /api/network-logs/{deviceId}/filter?startTimestamp=&endTimestamp= — device + time range
39+
get("/api/network-logs/{deviceId}/filter") {
40+
val deviceId = call.parameters["deviceId"]
41+
val startTimestamp = call.request.queryParameters["startTimestamp"]?.toLongOrNull()
42+
val endTimestamp = call.request.queryParameters["endTimestamp"]?.toLongOrNull()
43+
44+
val calls = getNetworkCalls(deviceId, startTimestamp, endTimestamp)
45+
call.respondJson(json, calls, deviceId = deviceId, startTimestamp = startTimestamp, endTimestamp = endTimestamp)
46+
}
47+
}
48+
49+
private suspend fun ApplicationCall.respondJson(
50+
json: Json,
51+
calls: List<NetworkCallWithDeviceId>,
52+
deviceId: String?,
53+
startTimestamp: Long?,
54+
endTimestamp: Long?,
55+
) {
56+
try {
57+
val exportedCalls = calls.map { networkCallWithDeviceId ->
58+
val networkCall = networkCallWithDeviceId.call
59+
val storedDeviceId = networkCallWithDeviceId.deviceId
60+
NetworkCallExport(
61+
callId = networkCall.callId,
62+
method = networkCall.request.method,
63+
url = networkCall.request.url,
64+
startTime = networkCall.request.startTime,
65+
startTimeFormatted = networkCall.request.startTimeFormatted,
66+
statusCode = when (val response = networkCall.response) {
67+
is FloconNetworkCallDomainModel.Response.Success ->
68+
when (val info = response.specificInfos) {
69+
is FloconNetworkCallDomainModel.Response.Success.SpecificInfos.Http -> info.httpCode
70+
else -> null
71+
}
72+
else -> null
73+
},
74+
durationMs = networkCall.response?.durationMs,
75+
requestHeaders = networkCall.request.headers,
76+
responseHeaders = when (val response = networkCall.response) {
77+
is FloconNetworkCallDomainModel.Response.Success -> response.headers
78+
else -> null
79+
},
80+
requestBody = networkCall.request.body,
81+
responseBody = when (val response = networkCall.response) {
82+
is FloconNetworkCallDomainModel.Response.Success -> response.body
83+
else -> null
84+
},
85+
contentType = when (val response = networkCall.response) {
86+
is FloconNetworkCallDomainModel.Response.Success -> response.contentType
87+
else -> null
88+
},
89+
deviceId = storedDeviceId,
90+
appInstance = networkCall.appInstance,
91+
)
92+
}
93+
94+
val response = NetworkLogsExportResponse(
95+
data = exportedCalls,
96+
metadata = ExportMetadata(
97+
exportedAt = System.currentTimeMillis(),
98+
totalItems = exportedCalls.size,
99+
filteredBy = if (deviceId != null || startTimestamp != null || endTimestamp != null) {
100+
FilterCriteria(
101+
deviceId = deviceId,
102+
startTimestamp = startTimestamp,
103+
endTimestamp = endTimestamp,
104+
)
105+
} else null,
106+
),
107+
)
108+
109+
respondText(
110+
json.encodeToString(NetworkLogsExportResponse.serializer(), response),
111+
ContentType.Application.Json,
112+
HttpStatusCode.OK,
113+
)
114+
} catch (e: Exception) {
115+
Logger.e("Error exporting network logs", e)
116+
respondText(
117+
json.encodeToString(
118+
MapSerializer(serializer<String>(), serializer<String>()),
119+
mapOf("error" to (e.message ?: "Unknown error")),
120+
),
121+
ContentType.Application.Json,
122+
HttpStatusCode.InternalServerError,
123+
)
124+
}
125+
}
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
package com.flocon.data.remote.server
22

3+
import io.github.openflocon.domain.network.repository.NetworkRepository
34
import kotlinx.serialization.json.Json
45

5-
actual fun getServer(json: Json): Server = ServerJvm(json)
6+
actual fun getServer(
7+
json: Json,
8+
networkRepository: Lazy<NetworkRepository>,
9+
): Server = ServerJvm(json, networkRepository)

FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/server/ServerJvm.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.flocon.data.remote.models.FloconDeviceIdAndPackageNameDataModel
55
import com.flocon.data.remote.models.FloconIncomingMessageDataModel
66
import com.flocon.data.remote.models.FloconOutgoingMessageDataModel
77
import io.github.openflocon.domain.messages.models.FloconReceivedFileDomainModel
8+
import io.github.openflocon.domain.network.repository.NetworkRepository
89
import io.ktor.http.content.PartData
910
import io.ktor.http.content.forEachPart
1011
import io.ktor.http.content.streamProvider
@@ -41,6 +42,7 @@ import kotlin.time.Duration.Companion.seconds
4142

4243
class ServerJvm(
4344
private val json: Json,
45+
private val networkRepository: Lazy<NetworkRepository>,
4446
) : Server {
4547
private val _receivedMessages = Channel<FloconIncomingMessageDataModel>()
4648
override val receivedMessages = _receivedMessages.receiveAsFlow()
@@ -251,6 +253,14 @@ class ServerJvm(
251253

252254
call.respondText("file received : ${savedFile?.absolutePath ?: "inconnu"}")
253255
}
256+
257+
// Add network export routes
258+
networkExportRoutes(
259+
json = json,
260+
getNetworkCalls = { deviceId, startTimestamp, endTimestamp ->
261+
networkRepository.value.getAllNetworkCalls(deviceId, startTimestamp, endTimestamp)
262+
}
263+
)
254264
}
255265
}.start(wait = false)
256266

0 commit comments

Comments
 (0)