feat(kotlin-platform-sdk): add diagnostics module (Phase 2.3)
New diagnostics package in kotlin-platform-sdk: - DiagnosticsTypes: Session, TraceSpan, LogEntry, Breadcrumb, NetworkRequest - DiagnosticsClient: singleton with StateFlow for reactive state - BreadcrumbTrail: ring buffer (max 100) for timeline - NetworkInterceptor: OkHttp interceptor for HTTP capture - DeviceStateCollector: battery, memory, storage, network - 16+ JUnit tests for types and breadcrumbs Features: - configure()/start()/stop() lifecycle - trace() suspend span wrapper - log() with breadcrumb integration - ETag-based config polling - 30-second batch flush
This commit is contained in:
parent
abcf817cb3
commit
fc8f8d33dc
@ -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<DiagnosticsBreadcrumb>()
|
||||||
|
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<String, String>? = 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<DiagnosticsBreadcrumb> {
|
||||||
|
return breadcrumbs.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get last N breadcrumbs
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun getLast(n: Int): List<DiagnosticsBreadcrumb> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String, Any>? = null)
|
||||||
|
fun info(message: String, metadata: Map<String, Any>? = null)
|
||||||
|
fun warn(message: String, metadata: Map<String, Any>? = null)
|
||||||
|
fun error(message: String, metadata: Map<String, Any>? = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op logger
|
||||||
|
*/
|
||||||
|
class NoOpDiagnosticsLogger : DiagnosticsLogger {
|
||||||
|
override fun debug(message: String, metadata: Map<String, Any>?) {}
|
||||||
|
override fun info(message: String, metadata: Map<String, Any>?) {}
|
||||||
|
override fun warn(message: String, metadata: Map<String, Any>?) {}
|
||||||
|
override fun error(message: String, metadata: Map<String, Any>?) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android Log-based logger
|
||||||
|
*/
|
||||||
|
class AndroidDiagnosticsLogger(private val tag: String = "ByteLystDiagnostics") : DiagnosticsLogger {
|
||||||
|
override fun debug(message: String, metadata: Map<String, Any>?) {
|
||||||
|
android.util.Log.d(tag, message)
|
||||||
|
}
|
||||||
|
override fun info(message: String, metadata: Map<String, Any>?) {
|
||||||
|
android.util.Log.i(tag, message)
|
||||||
|
}
|
||||||
|
override fun warn(message: String, metadata: Map<String, Any>?) {
|
||||||
|
android.util.Log.w(tag, message)
|
||||||
|
}
|
||||||
|
override fun error(message: String, metadata: Map<String, Any>?) {
|
||||||
|
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>(DiagnosticsClientState.Idle)
|
||||||
|
val state: StateFlow<DiagnosticsClientState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val breadcrumbs = BreadcrumbTrail(maxSize = config.maxBreadcrumbs)
|
||||||
|
private val logBuffer = mutableListOf<DiagnosticsLogEntry>()
|
||||||
|
private val traceBuffer = mutableListOf<DiagnosticsTraceSpan>()
|
||||||
|
private val networkBuffer = mutableListOf<DiagnosticsNetworkRequest>()
|
||||||
|
|
||||||
|
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<String, String> = 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 <T> 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<String, String>? = null) {
|
||||||
|
breadcrumbs.add(category = category, message = message, data = data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all breadcrumbs
|
||||||
|
*/
|
||||||
|
fun getBreadcrumbs(): List<DiagnosticsBreadcrumb> {
|
||||||
|
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<DiagnosticsSession>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String, String> = 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<String, String> = 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<String, String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network request/response capture
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class DiagnosticsNetworkRequest(
|
||||||
|
val id: String,
|
||||||
|
val url: String,
|
||||||
|
val method: String,
|
||||||
|
val requestHeaders: Map<String, String> = emptyMap(),
|
||||||
|
val requestBody: String? = null,
|
||||||
|
val status: Int? = null,
|
||||||
|
val responseHeaders: Map<String, String>? = 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<DiagnosticsTraceSpan>? = null,
|
||||||
|
val logs: List<DiagnosticsLogEntry>? = null,
|
||||||
|
val breadcrumbs: List<DiagnosticsBreadcrumb>? = null,
|
||||||
|
val network: List<DiagnosticsNetworkRequest>? = null
|
||||||
|
)
|
||||||
@ -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<String, String>()
|
||||||
|
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<String, String>()
|
||||||
|
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)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user