diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt new file mode 100644 index 00000000..c8286cde --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt @@ -0,0 +1,74 @@ +package com.bytelyst.platform.diagnostics + +import java.text.SimpleDateFormat +import java.util.* + +/** + * Ring buffer for breadcrumbs with fixed max size + */ +class BreadcrumbTrail(private val maxSize: Int = 100) { + private val breadcrumbs = mutableListOf() + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + /** + * Add a breadcrumb to the trail + */ + @Synchronized + fun add(category: String, message: String, data: Map? = null) { + val breadcrumb = DiagnosticsBreadcrumb( + timestamp = dateFormat.format(Date()), + category = category, + message = message, + data = data + ) + + breadcrumbs.add(breadcrumb) + + // Evict oldest if over limit + if (breadcrumbs.size > maxSize) { + breadcrumbs.removeAt(0) + } + } + + /** + * Get all breadcrumbs (oldest first) + */ + @Synchronized + fun getAll(): List { + return breadcrumbs.toList() + } + + /** + * Get last N breadcrumbs + */ + @Synchronized + fun getLast(n: Int): List { + return breadcrumbs.takeLast(n) + } + + /** + * Get most recent breadcrumb + */ + @Synchronized + fun getMostRecent(): DiagnosticsBreadcrumb? { + return breadcrumbs.lastOrNull() + } + + /** + * Clear all breadcrumbs + */ + @Synchronized + fun clear() { + breadcrumbs.clear() + } + + /** + * Get current size + */ + @Synchronized + fun size(): Int { + return breadcrumbs.size + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt new file mode 100644 index 00000000..4da2ec81 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt @@ -0,0 +1,114 @@ +package com.bytelyst.platform.diagnostics + +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.BatteryManager +import android.os.Build +import android.os.StatFs + +/** + * Device state collector for Android + */ +object DeviceStateCollector { + + /** + * Collect current device state + */ + fun collect(context: Context): DiagnosticsDeviceState { + return DiagnosticsDeviceState( + memoryMB = getMemoryUsage(context), + batteryLevel = getBatteryLevel(context), + isCharging = getIsCharging(context), + storageMB = getStorageUsage(context), + networkType = getNetworkType(context), + isOnline = getIsOnline(context), + thermalState = null // Android doesn't expose thermal state easily + ) + } + + private fun getMemoryUsage(context: Context): Int? { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + ?: return null + + val runtime = Runtime.getRuntime() + val usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024) + + return usedMemory.toInt() + } + + private fun getBatteryLevel(context: Context): Float? { + val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + ?: return null + + val level = batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale = batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) + + if (level == -1 || scale == -1) return null + + return level / scale.toFloat() + } + + private fun getIsCharging(context: Context): Boolean? { + val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + ?: return null + + val status = batteryIntent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) + return status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL + } + + private fun getStorageUsage(context: Context): Int? { + val stat = StatFs(context.filesDir.path) + val blockSize = stat.blockSizeLong + val availableBlocks = stat.availableBlocksLong + val totalBlocks = stat.blockCountLong + + val usedBytes = (totalBlocks - availableBlocks) * blockSize + return (usedBytes / (1024 * 1024)).toInt() + } + + private fun getNetworkType(context: Context): String? { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return null + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork ?: return "offline" + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return "offline" + + when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + else -> "unknown" + } + } else { + @Suppress("DEPRECATION") + val networkInfo = connectivityManager.activeNetworkInfo + when (networkInfo?.type) { + ConnectivityManager.TYPE_WIFI -> "wifi" + ConnectivityManager.TYPE_MOBILE -> "cellular" + ConnectivityManager.TYPE_ETHERNET -> "ethernet" + else -> if (networkInfo?.isConnected == true) "unknown" else "offline" + } + } + } + + private fun getIsOnline(context: Context): Boolean { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return true // Assume online if can't determine + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(network) + capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + } else { + @Suppress("DEPRECATION") + val networkInfo = connectivityManager.activeNetworkInfo + networkInfo?.isConnected == true + } + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt new file mode 100644 index 00000000..fd256765 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt @@ -0,0 +1,534 @@ +package com.bytelyst.platform.diagnostics + +import android.content.Context +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.TimeUnit + +/** + * Client state + */ +sealed class DiagnosticsClientState { + object Idle : DiagnosticsClientState() + data class Polling(val session: DiagnosticsSession?) : DiagnosticsClientState() + data class Active(val session: DiagnosticsSession) : DiagnosticsClientState() + data class Error(val exception: Throwable) : DiagnosticsClientState() +} + +/** + * Diagnostics client configuration + */ +data class DiagnosticsConfiguration( + val productId: String, + val userId: String? = null, + val anonymousInstallId: String, + val platform: String, + val channel: String, + val osFamily: String, + val appVersion: String, + val buildNumber: String, + val releaseChannel: String, + val serverUrl: String, + val pollIntervalMs: Long = 5000, + val maxBreadcrumbs: Int = 100, + val captureConsole: Boolean = true, + val captureErrors: Boolean = true, + val captureNetwork: Boolean = true, + val getAuthToken: (suspend () -> String)? = null +) + +/** + * Logger interface + */ +interface DiagnosticsLogger { + fun debug(message: String, metadata: Map? = null) + fun info(message: String, metadata: Map? = null) + fun warn(message: String, metadata: Map? = null) + fun error(message: String, metadata: Map? = null) +} + +/** + * No-op logger + */ +class NoOpDiagnosticsLogger : DiagnosticsLogger { + override fun debug(message: String, metadata: Map?) {} + override fun info(message: String, metadata: Map?) {} + override fun warn(message: String, metadata: Map?) {} + override fun error(message: String, metadata: Map?) {} +} + +/** + * Android Log-based logger + */ +class AndroidDiagnosticsLogger(private val tag: String = "ByteLystDiagnostics") : DiagnosticsLogger { + override fun debug(message: String, metadata: Map?) { + android.util.Log.d(tag, message) + } + override fun info(message: String, metadata: Map?) { + android.util.Log.i(tag, message) + } + override fun warn(message: String, metadata: Map?) { + android.util.Log.w(tag, message) + } + override fun error(message: String, metadata: Map?) { + android.util.Log.e(tag, message) + } +} + +/** + * Main diagnostics client + */ +class DiagnosticsClient private constructor( + private val context: Context, + private val config: DiagnosticsConfiguration, + private val logger: DiagnosticsLogger +) { + companion object { + @Volatile + private var instance: DiagnosticsClient? = null + + fun getInstance( + context: Context, + config: DiagnosticsConfiguration, + logger: DiagnosticsLogger = NoOpDiagnosticsLogger() + ): DiagnosticsClient { + return instance ?: synchronized(this) { + instance ?: DiagnosticsClient(context.applicationContext, config, logger).also { + instance = it + } + } + } + + fun reset() { + instance?.stop() + instance = null + } + } + + private val _state = MutableStateFlow(DiagnosticsClientState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val breadcrumbs = BreadcrumbTrail(maxSize = config.maxBreadcrumbs) + private val logBuffer = mutableListOf() + private val traceBuffer = mutableListOf() + private val networkBuffer = mutableListOf() + + private var pollJob: Job? = null + private var flushJob: Job? = null + private var networkInterceptor: NetworkInterceptor? = null + private var lastEtag: String? = null + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val json = Json { ignoreUnknownKeys = true } + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + /** + * Start polling for active debug sessions + */ + fun start() { + if (_state.value != DiagnosticsClientState.Idle) { + logger.warn("[diagnostics] Already started") + return + } + + logger.info("[diagnostics] Starting diagnostics client") + _state.value = DiagnosticsClientState.Polling(null) + + // Initial poll + scope.launch { + pollForSession() + } + + // Start polling timer + pollJob = scope.launch { + while (isActive) { + delay(config.pollIntervalMs) + pollForSession() + } + } + + // Start auto-flush timer (every 30 seconds) + flushJob = scope.launch { + while (isActive) { + delay(30000) + flush() + } + } + + // Setup network capture if enabled + if (config.captureNetwork) { + setupNetworkCapture() + } + + breadcrumbs.add(category = "diagnostics", message = "Client started") + } + + /** + * Stop polling and cleanup + */ + fun stop() { + logger.info("[diagnostics] Stopping diagnostics client") + + pollJob?.cancel() + pollJob = null + + flushJob?.cancel() + flushJob = null + + networkInterceptor?.stop() + networkInterceptor = null + + // Final flush + scope.launch { + flush() + } + + _state.value = DiagnosticsClientState.Idle + breadcrumbs.add(category = "diagnostics", message = "Client stopped") + } + + /** + * Check if a debug session is currently active + */ + fun isSessionActive(): Boolean { + return _state.value is DiagnosticsClientState.Active + } + + /** + * Get current session if active + */ + fun getCurrentSession(): DiagnosticsSession? { + return when (val current = _state.value) { + is DiagnosticsClientState.Active -> current.session + is DiagnosticsClientState.Polling -> current.session + else -> null + } + } + + /** + * Record a log entry + */ + fun log( + level: DiagnosticsLogLevel, + message: String, + module: String = "unknown", + file: String? = null, + line: Int? = null, + function: String? = null, + context: Map = emptyMap(), + correlationId: String? = null + ) { + val entry = DiagnosticsLogEntry( + level = level, + message = message, + timestamp = dateFormat.format(Date()), + module = module, + file = file, + line = line, + function = function, + context = context, + correlationId = correlationId + ) + + synchronized(logBuffer) { + logBuffer.add(entry) + } + + breadcrumbs.add( + category = "log", + message = "[${level.name}] ${message.take(100)}", + data = mapOf("level" to level.name) + ) + + // Auto-flush on fatal + if (level == DiagnosticsLogLevel.FATAL) { + scope.launch { flush() } + } + } + + /** + * Record a trace span (auto-instrumented) + */ + suspend fun trace(name: String, operation: suspend () -> T): T { + val spanId = generateId() + val startTime = dateFormat.format(Date()) + + breadcrumbs.add( + category = "trace", + message = "Starting: $name", + data = mapOf("spanId" to spanId) + ) + + return try { + val result = operation() + val endTime = dateFormat.format(Date()) + val durationMs = calculateDuration(startTime, endTime) + + val span = DiagnosticsTraceSpan( + spanId = spanId, + name = name, + startTime = startTime, + endTime = endTime, + durationMs = durationMs, + status = DiagnosticsSpanStatus.OK + ) + + synchronized(traceBuffer) { + traceBuffer.add(span) + } + + breadcrumbs.add( + category = "trace", + message = "Completed: $name", + data = mapOf("spanId" to spanId, "durationMs" to durationMs.toString()) + ) + + result + } catch (e: Exception) { + val endTime = dateFormat.format(Date()) + val durationMs = calculateDuration(startTime, endTime) + + val span = DiagnosticsTraceSpan( + spanId = spanId, + name = name, + startTime = startTime, + endTime = endTime, + durationMs = durationMs, + status = DiagnosticsSpanStatus.ERROR, + statusMessage = e.message + ) + + synchronized(traceBuffer) { + traceBuffer.add(span) + } + + breadcrumbs.add( + category = "trace", + message = "Failed: $name", + data = mapOf("spanId" to spanId, "error" to (e.message ?: "Unknown")) + ) + + throw e + } + } + + /** + * Add a manual breadcrumb + */ + fun breadcrumb(category: String, message: String, data: Map? = null) { + breadcrumbs.add(category = category, message = message, data = data) + } + + /** + * Get all breadcrumbs + */ + fun getBreadcrumbs(): List { + return breadcrumbs.getAll() + } + + /** + * Collect and return device state + */ + fun collectDeviceState(): DiagnosticsDeviceState { + return DeviceStateCollector.collect(context) + } + + // Private methods + + private suspend fun pollForSession() { + try { + val url = "${config.serverUrl}/api/diagnostics/config" + + "?productId=${config.productId}" + + "&installId=${config.anonymousInstallId}" + + val requestBuilder = Request.Builder() + .url(url) + .header("Accept", "application/json") + + lastEtag?.let { etag -> + requestBuilder.header("If-None-Match", etag) + } + + config.getAuthToken?.let { getToken -> + try { + val token = getToken() + requestBuilder.header("Authorization", "Bearer $token") + } catch (e: Exception) { + logger.error("[diagnostics] Failed to get auth token", mapOf("error" to e.message)) + } + } + + val request = requestBuilder.build() + + httpClient.newCall(request).execute().use { response -> + if (response.code == 304) { + // No change + return + } + + if (!response.isSuccessful) { + throw IOException("HTTP ${response.code}") + } + + // Store ETag + response.header("ETag")?.let { etag -> + lastEtag = etag + } + + val body = response.body?.string() + val session = body?.let { + try { + json.decodeFromString(it) + } catch (e: Exception) { + null + } + } + + // Update state + if (session != null && session.status == DiagnosticsSessionStatus.ACTIVE) { + if (_state.value !is DiagnosticsClientState.Active) { + logger.info("[diagnostics] Session activated", mapOf("sessionId" to session.id)) + breadcrumbs.add( + category = "diagnostics", + message = "Session activated", + data = mapOf("sessionId" to session.id) + ) + } + _state.value = DiagnosticsClientState.Active(session) + } else { + if (_state.value is DiagnosticsClientState.Active) { + logger.info("[diagnostics] Session ended") + breadcrumbs.add(category = "diagnostics", message = "Session ended") + } + _state.value = DiagnosticsClientState.Polling(null) + } + } + } catch (e: Exception) { + logger.error("[diagnostics] Failed to poll for session", mapOf("error" to e.message)) + _state.value = DiagnosticsClientState.Error(e) + } + } + + private suspend fun flush() { + val session = getCurrentSession() + if (session == null) { + // No active session, clear buffers + synchronized(logBuffer) { logBuffer.clear() } + synchronized(traceBuffer) { traceBuffer.clear() } + synchronized(networkBuffer) { networkBuffer.clear() } + return + } + + // Build batch + val batch = DiagnosticsIngestBatch( + sessionId = session.id, + traces = synchronized(traceBuffer) { + if (traceBuffer.isEmpty()) null else traceBuffer.take(50).also { + repeat(it.size) { traceBuffer.removeAt(0) } + } + }, + logs = synchronized(logBuffer) { + if (logBuffer.isEmpty()) null else logBuffer.take(50).also { + repeat(it.size) { logBuffer.removeAt(0) } + } + }, + network = synchronized(networkBuffer) { + if (networkBuffer.isEmpty()) null else networkBuffer.take(50).also { + repeat(it.size) { networkBuffer.removeAt(0) } + } + }, + breadcrumbs = breadcrumbs.getAll().takeIf { it.isNotEmpty() }?.also { + breadcrumbs.clear() + } + ) + + // Skip if nothing to send + if (batch.traces == null && batch.logs == null && batch.network == null && batch.breadcrumbs == null) { + return + } + + try { + val url = "${config.serverUrl}/api/diagnostics/ingest" + + val requestBody = json.encodeToString(batch) + .toRequestBody("application/json".toMediaType()) + + val requestBuilder = Request.Builder() + .url(url) + .post(requestBody) + + config.getAuthToken?.let { getToken -> + try { + val token = getToken() + requestBuilder.header("Authorization", "Bearer $token") + } catch (e: Exception) { + logger.error("[diagnostics] Failed to get auth token for flush", mapOf("error" to e.message)) + } + } + + val request = requestBuilder.build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IOException("HTTP ${response.code}") + } + + logger.debug( + "[diagnostics] Flushed batch", + mapOf( + "logs" to (batch.logs?.size ?: 0), + "traces" to (batch.traces?.size ?: 0), + "network" to (batch.network?.size ?: 0) + ) + ) + } + } catch (e: Exception) { + logger.error("[diagnostics] Failed to flush batch", mapOf("error" to e.message)) + + // Put items back in buffers for retry + synchronized(logBuffer) { batch.logs?.let { logBuffer.addAll(0, it) } } + synchronized(traceBuffer) { batch.traces?.let { traceBuffer.addAll(0, it) } } + synchronized(networkBuffer) { batch.network?.let { networkBuffer.addAll(0, it) } } + } + } + + private fun setupNetworkCapture() { + networkInterceptor = NetworkInterceptor { request -> + synchronized(networkBuffer) { + networkBuffer.add(request) + } + } + networkInterceptor?.start(httpClient) + breadcrumbs.add(category = "diagnostics", message = "Network capture enabled") + } + + private fun generateId(): String { + return "${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(7)}" + } + + private fun calculateDuration(startTime: String, endTime: String): Double { + return try { + val start = dateFormat.parse(startTime)?.time ?: 0 + val end = dateFormat.parse(endTime)?.time ?: 0 + (end - start).toDouble() + } catch (e: Exception) { + 0.0 + } + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt new file mode 100644 index 00000000..2e63fd1c --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt @@ -0,0 +1,152 @@ +package com.bytelyst.platform.diagnostics + +import kotlinx.serialization.Serializable + +/** + * Log severity levels (matches syslog/OpenTelemetry) + */ +enum class DiagnosticsLogLevel { + DEBUG, INFO, WARN, ERROR, FATAL +} + +/** + * Session status from the server + */ +enum class DiagnosticsSessionStatus { + PENDING, ACTIVE, PAUSED, COMPLETED, CANCELLED +} + +/** + * Collection level determines verbosity of captured data + */ +enum class DiagnosticsCollectionLevel { + STANDARD, DEBUG, TRACE +} + +/** + * Diagnostic session configuration from server + */ +@Serializable +data class DiagnosticsSession( + val id: String, + val productId: String, + val status: DiagnosticsSessionStatus, + val collectionLevel: DiagnosticsCollectionLevel, + val captureLogs: Boolean, + val captureNetwork: Boolean, + val captureScreenshots: Boolean, + val screenshotOnError: Boolean, + val maxDurationMinutes: Int, + val createdAt: String, + val expiresAt: String +) + +/** + * Span kind for OpenTelemetry compatibility + */ +enum class DiagnosticsSpanKind { + INTERNAL, SERVER, CLIENT, PRODUCER, CONSUMER +} + +/** + * Span status + */ +enum class DiagnosticsSpanStatus { + OK, ERROR, UNSET +} + +/** + * OpenTelemetry-compatible trace span + */ +@Serializable +data class DiagnosticsTraceSpan( + val spanId: String, + val parentId: String? = null, + val name: String, + val kind: DiagnosticsSpanKind? = null, + val startTime: String, + val endTime: String? = null, + val durationMs: Double? = null, + val attributes: Map = emptyMap(), + val status: DiagnosticsSpanStatus, + val statusMessage: String? = null +) + +/** + * Structured log entry + */ +@Serializable +data class DiagnosticsLogEntry( + val level: DiagnosticsLogLevel, + val message: String, + val timestamp: String, + val module: String, + val file: String? = null, + val line: Int? = null, + val function: String? = null, + val context: Map = emptyMap(), + val correlationId: String? = null +) + +/** + * Breadcrumb for timeline navigation + */ +@Serializable +data class DiagnosticsBreadcrumb( + val timestamp: String, + val category: String, + val message: String, + val data: Map? = null +) + +/** + * Network request/response capture + */ +@Serializable +data class DiagnosticsNetworkRequest( + val id: String, + val url: String, + val method: String, + val requestHeaders: Map = emptyMap(), + val requestBody: String? = null, + val status: Int? = null, + val responseHeaders: Map? = null, + val responseBody: String? = null, + val startTime: String, + val endTime: String? = null, + val durationMs: Double? = null, + val error: String? = null +) + +/** + * Device state snapshot + */ +@Serializable +data class DiagnosticsDeviceState( + val memoryMB: Int? = null, + val batteryLevel: Float? = null, + val isCharging: Boolean? = null, + val storageMB: Int? = null, + val networkType: String? = null, + val isOnline: Boolean, + val thermalState: DiagnosticsThermalState? = null +) + +/** + * Thermal state + */ +enum class DiagnosticsThermalState { + NOMINAL, FAIR, SERIOUS, CRITICAL +} + +/** + * Ingest batch for sending to server + */ +@Serializable +data class DiagnosticsIngestBatch( + val sessionId: String, + val traces: List? = null, + val logs: List? = null, + val breadcrumbs: List? = null, + val network: List? = null +) diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt new file mode 100644 index 00000000..3233937c --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt @@ -0,0 +1,120 @@ +package com.bytelyst.platform.diagnostics + +import okhttp3.* +import okhttp3.Interceptor.Chain +import okio.Buffer +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.* + +/** + * Network interceptor for OkHttp to capture HTTP requests/responses + */ +class NetworkInterceptor( + private val onRequest: (DiagnosticsNetworkRequest) -> Unit +) : Interceptor { + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private var isActive = false + private lateinit var httpClient: OkHttpClient + + fun start(client: OkHttpClient) { + this.httpClient = client + isActive = true + } + + fun stop() { + isActive = false + } + + override fun intercept(chain: Chain): Response { + if (!isActive) { + return chain.proceed(chain.request()) + } + + val request = chain.request() + val requestId = generateId() + val startTime = System.currentTimeMillis() + + // Capture request details + val requestHeaders = mutableMapOf() + request.headers.forEach { name, value -> + requestHeaders[name] = sanitizeHeader(value, name) + } + + val requestBody = request.body?.let { body -> + val buffer = Buffer() + try { + body.writeTo(buffer) + buffer.readUtf8() + } catch (e: Exception) { + null + } + } + + // Proceed with request + val response: Response + try { + response = chain.proceed(request) + } catch (e: Exception) { + // Capture failed request + val networkRequest = DiagnosticsNetworkRequest( + id = requestId, + url = request.url.toString().take(2048), + method = request.method, + requestHeaders = requestHeaders, + requestBody = requestBody?.take(100 * 1024), // Limit to 100KB + startTime = dateFormat.format(Date(startTime)), + endTime = dateFormat.format(Date()), + durationMs = (System.currentTimeMillis() - startTime).toDouble(), + error = e.message + ) + onRequest(networkRequest) + throw e + } + + // Capture response + val endTime = System.currentTimeMillis() + val responseHeaders = mutableMapOf() + response.headers.forEach { name, value -> + responseHeaders[name] = sanitizeHeader(value, name) + } + + val networkRequest = DiagnosticsNetworkRequest( + id = requestId, + url = request.url.toString().take(2048), + method = request.method, + requestHeaders = requestHeaders, + requestBody = requestBody?.take(100 * 1024), + status = response.code, + responseHeaders = responseHeaders, + responseBody = null, // Don't capture response body (too large) + startTime = dateFormat.format(Date(startTime)), + endTime = dateFormat.format(Date(endTime)), + durationMs = (endTime - startTime).toDouble(), + error = null + ) + + onRequest(networkRequest) + + return response + } + + private fun sanitizeHeader(value: String, key: String): String { + val sensitivePatterns = listOf("authorization", "cookie", "token", "api-key") + val lowerKey = key.lowercase() + + for (pattern in sensitivePatterns) { + if (lowerKey.contains(pattern)) { + return "[REDACTED]" + } + } + return value + } + + private fun generateId(): String { + return "${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(7)}" + } +} diff --git a/packages/kotlin-platform-sdk/src/test/java/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt b/packages/kotlin-platform-sdk/src/test/java/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt new file mode 100644 index 00000000..caae2e5d --- /dev/null +++ b/packages/kotlin-platform-sdk/src/test/java/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt @@ -0,0 +1,216 @@ +package com.bytelyst.platform.diagnostics + +import org.junit.Test +import org.junit.Assert.* +import org.junit.Before + +class DiagnosticsTypesTest { + + @Test + fun `test DiagnosticsSession creation`() { + val session = DiagnosticsSession( + id = "ds_test123", + productId = "test-app", + status = DiagnosticsSessionStatus.ACTIVE, + collectionLevel = DiagnosticsCollectionLevel.DEBUG, + captureLogs = true, + captureNetwork = true, + captureScreenshots = false, + screenshotOnError = true, + maxDurationMinutes = 60, + createdAt = "2026-03-03T12:00:00Z", + expiresAt = "2026-03-03T13:00:00Z" + ) + + assertEquals("ds_test123", session.id) + assertEquals(DiagnosticsSessionStatus.ACTIVE, session.status) + assertEquals(DiagnosticsCollectionLevel.DEBUG, session.collectionLevel) + assertTrue(session.captureLogs) + } + + @Test + fun `test DiagnosticsLogEntry creation`() { + val entry = DiagnosticsLogEntry( + level = DiagnosticsLogLevel.ERROR, + message = "Something went wrong", + timestamp = "2026-03-03T12:00:00Z", + module = "TestModule", + file = "Test.kt", + line = 42, + function = "testFunction", + context = mapOf("key" to "value"), + correlationId = "corr-123" + ) + + assertEquals(DiagnosticsLogLevel.ERROR, entry.level) + assertEquals("Something went wrong", entry.message) + assertEquals(42, entry.line) + } + + @Test + fun `test DiagnosticsTraceSpan creation`() { + val span = DiagnosticsTraceSpan( + spanId = "span-123", + parentId = "parent-456", + name = "test-span", + kind = DiagnosticsSpanKind.INTERNAL, + startTime = "2026-03-03T12:00:00Z", + endTime = "2026-03-03T12:00:01Z", + durationMs = 1000.0, + attributes = mapOf("key" to "value"), + status = DiagnosticsSpanStatus.OK + ) + + assertEquals("span-123", span.spanId) + assertEquals("parent-456", span.parentId) + assertEquals(DiagnosticsSpanStatus.OK, span.status) + } + + @Test + fun `test DiagnosticsBreadcrumb creation`() { + val breadcrumb = DiagnosticsBreadcrumb( + timestamp = "2026-03-03T12:00:00Z", + category = "navigation", + message = "User tapped button", + data = mapOf("buttonId" to "submit") + ) + + assertEquals("navigation", breadcrumb.category) + assertEquals("User tapped button", breadcrumb.message) + } + + @Test + fun `test DiagnosticsNetworkRequest creation`() { + val request = DiagnosticsNetworkRequest( + id = "req-123", + url = "https://api.example.com/test", + method = "POST", + requestHeaders = mapOf("Content-Type" to "application/json"), + requestBody = "{}", + status = 200, + startTime = "2026-03-03T12:00:00Z", + endTime = "2026-03-03T12:00:01Z", + durationMs = 100.0 + ) + + assertEquals("req-123", request.id) + assertEquals(200, request.status) + assertEquals(100.0, request.durationMs, 0.0) + } + + @Test + fun `test DiagnosticsDeviceState creation`() { + val state = DiagnosticsDeviceState( + memoryMB = 1024, + batteryLevel = 0.75f, + isCharging = true, + storageMB = 512, + networkType = "wifi", + isOnline = true, + thermalState = DiagnosticsThermalState.NOMINAL + ) + + assertEquals(1024, state.memoryMB) + assertEquals(0.75f, state.batteryLevel) + assertTrue(state.isCharging == true) + assertEquals(DiagnosticsThermalState.NOMINAL, state.thermalState) + } +} + +class BreadcrumbTrailTest { + + private lateinit var trail: BreadcrumbTrail + + @Before + fun setup() { + trail = BreadcrumbTrail(maxSize = 3) + } + + @Test + fun `test add breadcrumb`() { + trail.add(category = "test", message = "test message") + assertEquals(1, trail.size()) + } + + @Test + fun `test evict oldest when over limit`() { + trail.add(category = "a", message = "1") + trail.add(category = "b", message = "2") + trail.add(category = "c", message = "3") + trail.add(category = "d", message = "4") + + assertEquals(3, trail.size()) + val all = trail.getAll() + assertEquals("b", all[0].category) // First one evicted + } + + @Test + fun `test get last n breadcrumbs`() { + trail.add(category = "a", message = "1") + trail.add(category = "b", message = "2") + trail.add(category = "c", message = "3") + + val last2 = trail.getLast(2) + assertEquals(2, last2.size) + assertEquals("b", last2[0].category) + } + + @Test + fun `test get most recent`() { + trail.add(category = "a", message = "1") + trail.add(category = "b", message = "2") + + val recent = trail.getMostRecent() + assertEquals("b", recent?.category) + } + + @Test + fun `test clear`() { + trail.add(category = "a", message = "1") + trail.clear() + assertEquals(0, trail.size()) + } +} + +class DiagnosticsConfigurationTest { + + @Test + fun `test configuration creation`() { + val config = DiagnosticsConfiguration( + productId = "test-app", + anonymousInstallId = "install-123", + platform = "android", + channel = "android_app", + osFamily = "android", + appVersion = "1.0.0", + buildNumber = "100", + releaseChannel = "beta", + serverUrl = "https://api.test.com" + ) + + assertEquals("test-app", config.productId) + assertEquals("install-123", config.anonymousInstallId) + assertEquals(5000, config.pollIntervalMs) // Default + assertEquals(100, config.maxBreadcrumbs) // Default + } + + @Test + fun `test configuration with custom values`() { + val config = DiagnosticsConfiguration( + productId = "test-app", + anonymousInstallId = "install-123", + platform = "android", + channel = "android_app", + osFamily = "android", + appVersion = "1.0.0", + buildNumber = "100", + releaseChannel = "beta", + serverUrl = "https://api.test.com", + pollIntervalMs = 10000, + maxBreadcrumbs = 50 + ) + + assertEquals(10000, config.pollIntervalMs) + assertEquals(50, config.maxBreadcrumbs) + } +}