XuqmGroup-AndroidSDK/sdk-bugcollect/src/main/java/com/xuqm/sdk/bugcollect/BugCollect.kt
XuqmGroup ceaaeca1fe fix(bugcollect): LogQueue 动态获取 bugCollectApiUrl,修复上传 URL 为空问题
- LogQueue.logApiUrl 改为动态 getter,每次上传取最新 URL
- flush() 在 URL 为空时跳过,等下次定时 flush
- LogUploader.resolveUrl 自动补全相对路径
2026-06-18 19:16:02 +08:00

218 行
8.2 KiB
Kotlin

package com.xuqm.sdk.bugcollect
import android.app.ActivityManager
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Build
import com.xuqm.sdk.XuqmSDK
import java.util.LinkedList
object BugCollect {
private var logLevel: LogLevel = LogLevel.WARN
private var environment: String = "production"
@Volatile private var queue: LogQueue? = null
@Volatile private var crashCaptureStarted = false
private val breadcrumbBuffer: LinkedList<Breadcrumb> = LinkedList()
private val breadcrumbLock = Any()
// SDK 未就绪时暂存的错误事件,就绪后自动 flush
private val pendingErrors = LinkedList<IssueEvent>()
private val pendingErrorsLock = Any()
private const val MAX_BREADCRUMBS = 50
private const val SDK_NAME = "bugcollect.android"
private const val SDK_VERSION = "1.1.0"
fun setLogLevel(level: LogLevel) { logLevel = level }
fun setEnvironment(env: String) { environment = env }
fun addBreadcrumb(
category: String,
message: String,
level: String = "info",
data: Map<String, Any?> = emptyMap(),
) {
synchronized(breadcrumbLock) {
if (breadcrumbBuffer.size >= MAX_BREADCRUMBS) breadcrumbBuffer.removeFirst()
breadcrumbBuffer.addLast(Breadcrumb(
timestamp = System.currentTimeMillis(),
category = category,
message = message,
level = level,
data = data,
))
}
}
fun event(name: String, properties: Map<String, Any?> = emptyMap()) {
if (!isReady()) return
queue().push(LogEvent(
name = name, properties = properties,
appKey = XuqmSDK.appKey, userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
platform = "android", release = appVersion(), environment = environment,
sdk = IssueEvent.SdkInfo(SDK_NAME, SDK_VERSION),
))
FunnelTracker.track(name, properties)
}
fun captureError(error: Throwable, tags: Map<String, Any?> = emptyMap(), fingerprint: String? = null) {
val crumbs = synchronized(breadcrumbLock) { breadcrumbBuffer.toList() }
val fp = fingerprint ?: Fingerprint.compute(
error.javaClass.simpleName,
error.message ?: error.javaClass.name,
error.stackTraceToString(),
)
val event = IssueEvent(
level = "error",
platform = "android",
fingerprint = fp,
appKey = XuqmSDK.appKey,
exception = IssueEvent.ExceptionInfo(
type = error.javaClass.simpleName,
value = error.message ?: error.javaClass.name,
stacktrace = error.stackTraceToString(),
),
breadcrumbs = crumbs,
release = appVersion(),
environment = environment,
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
device = buildDeviceInfo(),
tags = tags,
)
if (!isReady()) {
synchronized(pendingErrorsLock) { pendingErrors.add(event) }
return
}
flushPendingErrors()
queue().push(event)
}
fun captureCrash(error: Throwable, tags: Map<String, Any?> = emptyMap()) {
if (!isReady()) return
val crumbs = synchronized(breadcrumbLock) { breadcrumbBuffer.toList() }
queue().push(IssueEvent(
level = "fatal",
platform = "android",
fingerprint = Fingerprint.compute(
error.javaClass.simpleName,
error.message ?: error.javaClass.name,
error.stackTraceToString(),
),
appKey = XuqmSDK.appKey,
exception = IssueEvent.ExceptionInfo(
type = error.javaClass.simpleName,
value = error.message ?: error.javaClass.name,
stacktrace = error.stackTraceToString(),
),
breadcrumbs = crumbs,
release = appVersion(),
environment = environment,
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
device = buildDeviceInfo(),
tags = tags,
))
}
fun warn(message: String, tags: Map<String, Any?> = emptyMap()) {
if (logLevel.ordinal > LogLevel.WARN.ordinal) return
if (!isReady()) return
queue().push(IssueEvent(
level = "warning",
platform = "android",
fingerprint = Fingerprint.compute("Warning", message, ""),
appKey = XuqmSDK.appKey,
exception = IssueEvent.ExceptionInfo(type = "Warning", value = message),
release = appVersion(),
environment = environment,
userId = XuqmSDK.getUserId() ?: XuqmSDK.deviceId,
user = IssueEvent.UserInfo(id = XuqmSDK.getUserId() ?: XuqmSDK.deviceId),
tags = tags,
))
}
fun info(message: String, tags: Map<String, Any?> = emptyMap()) {
if (logLevel.ordinal > LogLevel.INFO.ordinal) return
event("__log_info", tags + ("message" to message))
}
/**
* 注册全局崩溃拦截器(幂等)。
* 通常由 BugCollectInitProvider 在 app 启动时自动调用,无需 app 代码主动触发。
* 如需在特定时机手动注册,调用此方法也是安全的。
*/
fun startCrashCapture() {
if (crashCaptureStarted) return
if (!XuqmSDK.isInitialized()) return
crashCaptureStarted = true
CrashCapture.start(
appContext = XuqmSDK.appContext,
appKey = XuqmSDK.appKey,
getUserId = { XuqmSDK.getUserId() ?: XuqmSDK.deviceId },
)
flushPendingErrors()
queue().uploadPendingCrashes()
}
/** 将 SDK 未就绪时暂存的错误事件 flush 到队列。 */
private fun flushPendingErrors() {
val events = synchronized(pendingErrorsLock) {
if (pendingErrors.isEmpty()) return
val list = pendingErrors.toList()
pendingErrors.clear()
list
}
events.forEach { queue().push(it) }
}
/** 上传上次崩溃落盘的文件(远端配置就绪后由 BugCollectInitProvider 自动调用)。 */
fun uploadPendingCrashes() {
if (isReady()) queue().uploadPendingCrashes()
}
fun defineFunnel(id: String, steps: List<String>) {
FunnelTracker.define(id, steps)
}
private fun isReady() = XuqmSDK.isInitialized() && XuqmSDK.bugCollectEnabled
private fun queue(): LogQueue {
return queue ?: synchronized(this) {
queue ?: LogQueue(
appKey = XuqmSDK.appKey,
appContext = XuqmSDK.appContext,
).also { queue = it }
}
}
private fun appVersion(): String = runCatching {
val ctx = XuqmSDK.appContext
ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "unknown"
}.getOrDefault("unknown")
private fun buildDeviceInfo(): IssueEvent.DeviceInfo {
val ctx = XuqmSDK.appContext
val actMgr = ctx.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
val memInfo = ActivityManager.MemoryInfo().also { actMgr?.getMemoryInfo(it) }
val freeMemMb = (memInfo.availMem / (1024L * 1024L)).toInt()
val isDebug = ctx.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
val isEmulator = Build.FINGERPRINT.startsWith("generic")
|| Build.FINGERPRINT.startsWith("unknown")
|| Build.MODEL.contains("Emulator", ignoreCase = true)
|| Build.MODEL.contains("Android SDK", ignoreCase = true)
return IssueEvent.DeviceInfo(
model = Build.MODEL,
manufacturer = Build.MANUFACTURER,
osName = "Android",
osVersion = Build.VERSION.RELEASE,
locale = java.util.Locale.getDefault().toString(),
timezone = java.util.TimeZone.getDefault().id,
isEmulator = isEmulator,
freeMemoryMb = freeMemMb,
buildType = if (isDebug) "debug" else "release",
)
}
}