feat: 添加 iOS BugCollect 崩溃收集模块
新增模块:XuqmBugCollectSDK - BugCollectSDK: 主入口,错误捕获 API - CrashCapture: 崩溃捕获(NSException + POSIX 信号) - IssueEvent/LogEvent: 数据模型 - Fingerprint: 错误指纹计算(SHA-256) - LogQueue: 内存队列 + 定时上传 - LogStorage: 崩溃文件持久化 - LogUploader: HTTP 上传到服务端 - FunnelTracker: 漏斗追踪 功能: - captureError/captureCrash 捕获错误 - addBreadcrumb 添加面包屑 - event 记录自定义事件 - warn/info 日志级别记录 - 自动初始化(onInitialize hook) Co-Authored-By: Claude <noreply@anthropic.com>
这个提交包含在:
父节点
ae9d033c85
当前提交
227bc66bfd
@ -6,15 +6,16 @@ let package = Package(
|
||||
platforms: [.iOS(.v16), .macOS(.v13)],
|
||||
products: [
|
||||
// Individual modules — use these for per-module consumption
|
||||
.library(name: "XuqmCoreSDK", targets: ["XuqmCoreSDK"]),
|
||||
.library(name: "XuqmImSDK", targets: ["XuqmImSDK"]),
|
||||
.library(name: "XuqmPushSDK", targets: ["XuqmPushSDK"]),
|
||||
.library(name: "XuqmUpdateSDK", targets: ["XuqmUpdateSDK"]),
|
||||
.library(name: "XuqmLicenseSDK", targets: ["XuqmLicenseSDK"]),
|
||||
.library(name: "XuqmFileSDK", targets: ["XuqmFileSDK"]),
|
||||
.library(name: "XuqmWebViewSDK", targets: ["XuqmWebViewSDK"]),
|
||||
.library(name: "XuqmCoreSDK", targets: ["XuqmCoreSDK"]),
|
||||
.library(name: "XuqmImSDK", targets: ["XuqmImSDK"]),
|
||||
.library(name: "XuqmPushSDK", targets: ["XuqmPushSDK"]),
|
||||
.library(name: "XuqmUpdateSDK", targets: ["XuqmUpdateSDK"]),
|
||||
.library(name: "XuqmLicenseSDK", targets: ["XuqmLicenseSDK"]),
|
||||
.library(name: "XuqmFileSDK", targets: ["XuqmFileSDK"]),
|
||||
.library(name: "XuqmWebViewSDK", targets: ["XuqmWebViewSDK"]),
|
||||
.library(name: "XuqmBugCollectSDK", targets: ["XuqmBugCollectSDK"]),
|
||||
// Convenience — backward compatible, includes all modules
|
||||
.library(name: "XuqmSDK", targets: ["XuqmSDK"]),
|
||||
.library(name: "XuqmSDK", targets: ["XuqmSDK"]),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@ -58,11 +59,18 @@ let package = Package(
|
||||
path: "Sources/XuqmWebViewSDK",
|
||||
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
|
||||
),
|
||||
.target(
|
||||
name: "XuqmBugCollectSDK",
|
||||
dependencies: ["XuqmCoreSDK"],
|
||||
path: "Sources/XuqmBugCollectSDK",
|
||||
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
|
||||
),
|
||||
.target(
|
||||
name: "XuqmSDK",
|
||||
dependencies: [
|
||||
"XuqmCoreSDK", "XuqmImSDK", "XuqmPushSDK",
|
||||
"XuqmUpdateSDK", "XuqmLicenseSDK", "XuqmFileSDK",
|
||||
"XuqmBugCollectSDK",
|
||||
],
|
||||
path: "Sources/XuqmSDK",
|
||||
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
|
||||
|
||||
@ -0,0 +1,290 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import XuqmCoreSDK
|
||||
|
||||
/// BugCollect 崩溃收集 SDK
|
||||
@MainActor
|
||||
public final class BugCollectSDK {
|
||||
|
||||
public static let shared = BugCollectSDK()
|
||||
|
||||
private var logLevel: LogLevel = .warn
|
||||
private var environment: String = "production"
|
||||
private var queue: LogQueue?
|
||||
private var crashCapture: CrashCapture?
|
||||
|
||||
private var breadcrumbBuffer: [Breadcrumb] = []
|
||||
private let breadcrumbLock = NSLock()
|
||||
private let maxBreadcrumbs = 50
|
||||
|
||||
private var pendingErrors: [IssueEvent] = []
|
||||
private let pendingErrorsLock = NSLock()
|
||||
|
||||
private let sdkName = "bugcollect.ios"
|
||||
private let sdkVersion = "1.0.0"
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// 设置日志级别
|
||||
public func setLogLevel(_ level: LogLevel) {
|
||||
self.logLevel = level
|
||||
}
|
||||
|
||||
/// 设置环境
|
||||
public func setEnvironment(_ env: String) {
|
||||
self.environment = env
|
||||
}
|
||||
|
||||
/// 添加面包屑
|
||||
public func addBreadcrumb(
|
||||
category: String,
|
||||
message: String,
|
||||
level: String = "info",
|
||||
data: [String: Any?] = [:]
|
||||
) {
|
||||
breadcrumbLock.lock()
|
||||
defer { breadcrumbLock.unlock() }
|
||||
|
||||
if breadcrumbBuffer.count >= maxBreadcrumbs {
|
||||
breadcrumbBuffer.removeFirst()
|
||||
}
|
||||
breadcrumbBuffer.append(Breadcrumb(
|
||||
timestamp: Int(Date().timeIntervalSince1970 * 1000),
|
||||
category: category,
|
||||
message: message,
|
||||
level: level,
|
||||
data: data
|
||||
))
|
||||
}
|
||||
|
||||
/// 记录事件
|
||||
public func event(name: String, properties: [String: Any?] = [:]) {
|
||||
guard isReady() else { return }
|
||||
|
||||
let logEvent = LogEvent(
|
||||
name: name,
|
||||
properties: properties,
|
||||
appKey: XuqmSDK.shared.requireConfig().appKey,
|
||||
userId: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId,
|
||||
platform: "ios",
|
||||
release: appVersion(),
|
||||
environment: environment,
|
||||
sdk: IssueEvent.SdkInfo(name: sdkName, version: sdkVersion)
|
||||
)
|
||||
queue?.push(logEvent)
|
||||
FunnelTracker.shared.track(name: name, properties: properties)
|
||||
}
|
||||
|
||||
/// 捕获错误
|
||||
public func captureError(_ error: Error, tags: [String: Any?] = [:], fingerprint: String? = nil) {
|
||||
let crumbs = breadcrumbLock.lockAndRun { breadcrumbBuffer }
|
||||
|
||||
let fp = fingerprint ?? Fingerprint.compute(
|
||||
type: String(describing: type(of: error)),
|
||||
message: error.localizedDescription,
|
||||
stacktrace: Thread.callStackSymbols.joined(separator: "\n")
|
||||
)
|
||||
|
||||
let issueEvent = IssueEvent(
|
||||
level: "error",
|
||||
platform: "ios",
|
||||
fingerprint: fp,
|
||||
appKey: XuqmSDK.shared.requireConfig().appKey,
|
||||
exception: IssueEvent.ExceptionInfo(
|
||||
type: String(describing: type(of: error)),
|
||||
value: error.localizedDescription,
|
||||
stacktrace: Thread.callStackSymbols.joined(separator: "\n")
|
||||
),
|
||||
breadcrumbs: crumbs,
|
||||
release: appVersion(),
|
||||
environment: environment,
|
||||
userId: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId,
|
||||
user: IssueEvent.UserInfo(id: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId),
|
||||
device: buildDeviceInfo(),
|
||||
tags: tags
|
||||
)
|
||||
|
||||
if !isReady() {
|
||||
pendingErrorsLock.lock()
|
||||
pendingErrors.append(issueEvent)
|
||||
pendingErrorsLock.unlock()
|
||||
return
|
||||
}
|
||||
|
||||
flushPendingErrors()
|
||||
queue?.push(issueEvent)
|
||||
}
|
||||
|
||||
/// 捕获致命错误
|
||||
public func captureCrash(_ error: Error, tags: [String: Any?] = [:]) {
|
||||
guard isReady() else { return }
|
||||
|
||||
let crumbs = breadcrumbLock.lockAndRun { breadcrumbBuffer }
|
||||
|
||||
let issueEvent = IssueEvent(
|
||||
level: "fatal",
|
||||
platform: "ios",
|
||||
fingerprint: Fingerprint.compute(
|
||||
type: String(describing: type(of: error)),
|
||||
message: error.localizedDescription,
|
||||
stacktrace: Thread.callStackSymbols.joined(separator: "\n")
|
||||
),
|
||||
appKey: XuqmSDK.shared.requireConfig().appKey,
|
||||
exception: IssueEvent.ExceptionInfo(
|
||||
type: String(describing: type(of: error)),
|
||||
value: error.localizedDescription,
|
||||
stacktrace: Thread.callStackSymbols.joined(separator: "\n")
|
||||
),
|
||||
breadcrumbs: crumbs,
|
||||
release: appVersion(),
|
||||
environment: environment,
|
||||
userId: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId,
|
||||
user: IssueEvent.UserInfo(id: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId),
|
||||
device: buildDeviceInfo(),
|
||||
tags: tags
|
||||
)
|
||||
queue?.push(issueEvent)
|
||||
}
|
||||
|
||||
/// 记录警告
|
||||
public func warn(_ message: String, tags: [String: Any?] = [:]) {
|
||||
guard logLevel.rawValue <= LogLevel.warn.rawValue else { return }
|
||||
guard isReady() else { return }
|
||||
|
||||
let issueEvent = IssueEvent(
|
||||
level: "warning",
|
||||
platform: "ios",
|
||||
fingerprint: Fingerprint.compute(type: "Warning", message: message, stacktrace: ""),
|
||||
appKey: XuqmSDK.shared.requireConfig().appKey,
|
||||
exception: IssueEvent.ExceptionInfo(type: "Warning", value: message),
|
||||
breadcrumbs: [],
|
||||
release: appVersion(),
|
||||
environment: environment,
|
||||
userId: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId,
|
||||
user: IssueEvent.UserInfo(id: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId),
|
||||
tags: tags
|
||||
)
|
||||
queue?.push(issueEvent)
|
||||
}
|
||||
|
||||
/// 记录信息
|
||||
public func info(_ message: String, tags: [String: Any?] = [:]) {
|
||||
guard logLevel.rawValue <= LogLevel.info.rawValue else { return }
|
||||
event(name: "__log_info", properties: tags.merging(["message": message]) { _, new in new })
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// 启动崩溃捕获
|
||||
public func startCrashCapture() {
|
||||
guard crashCapture == nil else { return }
|
||||
crashCapture = CrashCapture { [weak self] exception, stacktrace in
|
||||
self?.handleCrash(exception: exception, stacktrace: stacktrace)
|
||||
}
|
||||
crashCapture?.install()
|
||||
}
|
||||
|
||||
/// 初始化队列
|
||||
public func startQueue() {
|
||||
guard queue == nil else { return }
|
||||
let config = XuqmSDK.shared.requireConfig()
|
||||
queue = LogQueue(appKey: config.appKey, sdkName: sdkName, sdkVersion: sdkVersion)
|
||||
queue?.start()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var isReady: Bool {
|
||||
XuqmSDK.shared.isInitialized && queue != nil
|
||||
}
|
||||
|
||||
private func flushPendingErrors() {
|
||||
pendingErrorsLock.lock()
|
||||
let errors = pendingErrors
|
||||
pendingErrors = []
|
||||
pendingErrorsLock.unlock()
|
||||
|
||||
for error in errors {
|
||||
queue?.push(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCrash(exception: NSException, stacktrace: String) {
|
||||
let crumbs = breadcrumbLock.lockAndRun { breadcrumbBuffer }
|
||||
|
||||
let issueEvent = IssueEvent(
|
||||
level: "fatal",
|
||||
platform: "ios",
|
||||
fingerprint: Fingerprint.compute(
|
||||
type: exception.name.rawValue,
|
||||
message: exception.reason ?? "",
|
||||
stacktrace: stacktrace
|
||||
),
|
||||
appKey: XuqmSDK.shared.requireConfig().appKey,
|
||||
exception: IssueEvent.ExceptionInfo(
|
||||
type: exception.name.rawValue,
|
||||
value: exception.reason ?? "",
|
||||
stacktrace: stacktrace
|
||||
),
|
||||
breadcrumbs: crumbs,
|
||||
release: appVersion(),
|
||||
environment: environment,
|
||||
userId: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId,
|
||||
user: IssueEvent.UserInfo(id: XuqmSDK.shared.userId ?? XuqmSDK.shared.deviceId),
|
||||
device: buildDeviceInfo(),
|
||||
tags: [:]
|
||||
)
|
||||
|
||||
// 崩溃时同步写入磁盘
|
||||
LogStorage.saveCrash(issueEvent)
|
||||
}
|
||||
|
||||
private func appVersion() -> String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||
}
|
||||
|
||||
private func buildDeviceInfo() -> IssueEvent.DeviceInfo {
|
||||
let device = UIDevice.current
|
||||
let processInfo = ProcessInfo.processInfo
|
||||
|
||||
return IssueEvent.DeviceInfo(
|
||||
model: device.model,
|
||||
manufacturer: "Apple",
|
||||
osName: device.systemName,
|
||||
osVersion: device.systemVersion,
|
||||
locale: Locale.current.identifier,
|
||||
timezone: TimeZone.current.identifier,
|
||||
isEmulator: isSimulator(),
|
||||
freeMemoryMb: Int(processInfo.physicalMemory / 1024 / 1024),
|
||||
buildType: isDebug() ? "debug" : "release"
|
||||
)
|
||||
}
|
||||
|
||||
private func isSimulator() -> Bool {
|
||||
#if targetEnvironment(simulator)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
private func isDebug() -> Bool {
|
||||
#if DEBUG
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSLock Extension
|
||||
|
||||
private extension NSLock {
|
||||
func lockAndRun<T>(_ block: () -> T) -> T {
|
||||
lock()
|
||||
defer { unlock() }
|
||||
return block()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// 错误指纹计算
|
||||
public enum Fingerprint {
|
||||
|
||||
/// 计算错误指纹
|
||||
public static func compute(type: String, message: String, stacktrace: String) -> String {
|
||||
let normalizedMessage = normalizeMessage(message)
|
||||
let topFrames = extractTopFrames(stacktrace, count: 3)
|
||||
|
||||
let payload = "\(type):\(normalizedMessage):\(topFrames)"
|
||||
let data = Data(payload.utf8)
|
||||
let hash = SHA256.hash(data: data)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// 归一化消息(数字替换为 N,长 hex 替换为 H)
|
||||
private static func normalizeMessage(_ message: String) -> String {
|
||||
var result = message
|
||||
// 替换数字
|
||||
result = result.replacingOccurrences(of: #"\d+"#, with: "N", options: .regularExpression)
|
||||
// 替换长 hex 字符串
|
||||
result = result.replacingOccurrences(of: #"[0-9a-fA-F]{8,}"#, with: "H", options: .regularExpression)
|
||||
return result
|
||||
}
|
||||
|
||||
/// 提取堆栈顶部帧
|
||||
private static func extractTopFrames(_ stacktrace: String, count: Int) -> String {
|
||||
let lines = stacktrace.split(separator: "\n").prefix(count)
|
||||
return lines.joined(separator: "|")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,228 @@
|
||||
import Foundation
|
||||
|
||||
/// Issue 事件数据模型
|
||||
public struct IssueEvent: Codable, Sendable {
|
||||
public let level: String
|
||||
public let platform: String
|
||||
public let fingerprint: String
|
||||
public let appKey: String
|
||||
public let exception: ExceptionInfo?
|
||||
public let breadcrumbs: [Breadcrumb]
|
||||
public let release: String
|
||||
public let environment: String
|
||||
public let userId: String?
|
||||
public let user: UserInfo?
|
||||
public let device: DeviceInfo?
|
||||
public let tags: [String: AnyCodable]?
|
||||
public let sdk: SdkInfo?
|
||||
|
||||
public init(
|
||||
level: String,
|
||||
platform: String,
|
||||
fingerprint: String,
|
||||
appKey: String,
|
||||
exception: ExceptionInfo? = nil,
|
||||
breadcrumbs: [Breadcrumb] = [],
|
||||
release: String,
|
||||
environment: String,
|
||||
userId: String? = nil,
|
||||
user: UserInfo? = nil,
|
||||
device: DeviceInfo? = nil,
|
||||
tags: [String: Any?]? = nil,
|
||||
sdk: SdkInfo? = nil
|
||||
) {
|
||||
self.level = level
|
||||
self.platform = platform
|
||||
self.fingerprint = fingerprint
|
||||
self.appKey = appKey
|
||||
self.exception = exception
|
||||
self.breadcrumbs = breadcrumbs
|
||||
self.release = release
|
||||
self.environment = environment
|
||||
self.userId = userId
|
||||
self.user = user
|
||||
self.device = device
|
||||
self.tags = tags?.mapValues { AnyCodable($0) }
|
||||
self.sdk = sdk
|
||||
}
|
||||
|
||||
public struct ExceptionInfo: Codable, Sendable {
|
||||
public let type: String
|
||||
public let value: String
|
||||
public let stacktrace: String?
|
||||
|
||||
public init(type: String, value: String, stacktrace: String? = nil) {
|
||||
self.type = type
|
||||
self.value = value
|
||||
self.stacktrace = stacktrace
|
||||
}
|
||||
}
|
||||
|
||||
public struct UserInfo: Codable, Sendable {
|
||||
public let id: String
|
||||
|
||||
public init(id: String) {
|
||||
self.id = id
|
||||
}
|
||||
}
|
||||
|
||||
public struct DeviceInfo: Codable, Sendable {
|
||||
public let model: String
|
||||
public let manufacturer: String
|
||||
public let osName: String
|
||||
public let osVersion: String
|
||||
public let locale: String
|
||||
public let timezone: String
|
||||
public let isEmulator: Bool
|
||||
public let freeMemoryMb: Int
|
||||
public let buildType: String
|
||||
|
||||
public init(
|
||||
model: String,
|
||||
manufacturer: String,
|
||||
osName: String,
|
||||
osVersion: String,
|
||||
locale: String,
|
||||
timezone: String,
|
||||
isEmulator: Bool,
|
||||
freeMemoryMb: Int,
|
||||
buildType: String
|
||||
) {
|
||||
self.model = model
|
||||
self.manufacturer = manufacturer
|
||||
self.osName = osName
|
||||
self.osVersion = osVersion
|
||||
self.locale = locale
|
||||
self.timezone = timezone
|
||||
self.isEmulator = isEmulator
|
||||
self.freeMemoryMb = freeMemoryMb
|
||||
self.buildType = buildType
|
||||
}
|
||||
}
|
||||
|
||||
public struct SdkInfo: Codable, Sendable {
|
||||
public let name: String
|
||||
public let version: String
|
||||
|
||||
public init(name: String, version: String) {
|
||||
self.name = name
|
||||
self.version = version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 面包屑
|
||||
public struct Breadcrumb: Codable, Sendable {
|
||||
public let timestamp: Int
|
||||
public let category: String
|
||||
public let message: String
|
||||
public let level: String
|
||||
public let data: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
timestamp: Int,
|
||||
category: String,
|
||||
message: String,
|
||||
level: String,
|
||||
data: [String: Any?]? = nil
|
||||
) {
|
||||
self.timestamp = timestamp
|
||||
self.category = category
|
||||
self.message = message
|
||||
self.level = level
|
||||
self.data = data?.mapValues { AnyCodable($0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志事件
|
||||
public struct LogEvent: Codable, Sendable {
|
||||
public let name: String
|
||||
public let properties: [String: AnyCodable]?
|
||||
public let appKey: String
|
||||
public let userId: String
|
||||
public let platform: String
|
||||
public let release: String
|
||||
public let environment: String
|
||||
public let sdk: IssueEvent.SdkInfo
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
properties: [String: Any?]? = nil,
|
||||
appKey: String,
|
||||
userId: String,
|
||||
platform: String,
|
||||
release: String,
|
||||
environment: String,
|
||||
sdk: IssueEvent.SdkInfo
|
||||
) {
|
||||
self.name = name
|
||||
self.properties = properties?.mapValues { AnyCodable($0) }
|
||||
self.appKey = appKey
|
||||
self.userId = userId
|
||||
self.platform = platform
|
||||
self.release = release
|
||||
self.environment = environment
|
||||
self.sdk = sdk
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志级别
|
||||
public enum LogLevel: Int, Sendable {
|
||||
case debug = 0
|
||||
case info = 1
|
||||
case warn = 2
|
||||
case error = 3
|
||||
case none = 4
|
||||
}
|
||||
|
||||
/// AnyCodable 包装器
|
||||
public struct AnyCodable: Codable, Sendable {
|
||||
public let value: Any
|
||||
|
||||
public init(_ value: Any?) {
|
||||
self.value = value ?? ()
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self.value = ()
|
||||
} else if let bool = try? container.decode(Bool.self) {
|
||||
self.value = bool
|
||||
} else if let int = try? container.decode(Int.self) {
|
||||
self.value = int
|
||||
} else if let double = try? container.decode(Double.self) {
|
||||
self.value = double
|
||||
} else if let string = try? container.decode(String.self) {
|
||||
self.value = string
|
||||
} else if let array = try? container.decode([AnyCodable].self) {
|
||||
self.value = array.map { $0.value }
|
||||
} else if let dict = try? container.decode([String: AnyCodable].self) {
|
||||
self.value = dict.mapValues { $0.value }
|
||||
} else {
|
||||
self.value = ()
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch value {
|
||||
case is Void:
|
||||
try container.encodeNil()
|
||||
case let bool as Bool:
|
||||
try container.encode(bool)
|
||||
case let int as Int:
|
||||
try container.encode(int)
|
||||
case let double as Double:
|
||||
try container.encode(double)
|
||||
case let string as String:
|
||||
try container.encode(string)
|
||||
case let array as [Any]:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
case let dict as [String: Any]:
|
||||
try container.encode(dict.mapValues { AnyCodable($0) })
|
||||
default:
|
||||
try container.encodeNil()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// 崩溃捕获器
|
||||
final class CrashCapture {
|
||||
|
||||
typealias CrashHandler = (NSException, String) -> Void
|
||||
|
||||
private let handler: CrashHandler
|
||||
private var previousHandler: (@convention(c) (NSException) -> Void)?
|
||||
|
||||
init(handler: @escaping CrashHandler) {
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
/// 安装崩溃捕获
|
||||
func install() {
|
||||
// 捕获 Objective-C 异常
|
||||
previousHandler = NSGetUncaughtExceptionHandler()
|
||||
NSSetUncaughtExceptionHandler { exception in
|
||||
let stacktrace = exception.callStackSymbols.joined(separator: "\n")
|
||||
|
||||
// 保存到磁盘
|
||||
CrashCapture.handleCrash(exception: exception, stacktrace: stacktrace)
|
||||
|
||||
// 调用之前的 handler
|
||||
CrashCapture.shared?.previousHandler?(exception)
|
||||
}
|
||||
|
||||
// 捕获 POSIX 信号
|
||||
installSignalHandlers()
|
||||
}
|
||||
|
||||
private static var shared: CrashCapture?
|
||||
|
||||
private static func handleCrash(exception: NSException, stacktrace: String) {
|
||||
shared?.handler(exception, stacktrace)
|
||||
}
|
||||
|
||||
/// 安装信号处理器
|
||||
private func installSignalHandlers() {
|
||||
let signals = [SIGABRT, SIGSEGV, SIGBUS, SIGFPE, SIGILL, SIGTRAP]
|
||||
|
||||
for sig in signals {
|
||||
let prev = signal(sig) { sig in
|
||||
let exception = NSException(
|
||||
name: NSExceptionName("Signal \(sig)"),
|
||||
reason: "Received signal \(sig)",
|
||||
userInfo: nil
|
||||
)
|
||||
let stacktrace = Thread.callStackSymbols.joined(separator: "\n")
|
||||
|
||||
CrashCapture.handleCrash(exception: exception, stacktrace: stacktrace)
|
||||
|
||||
// 恢复默认处理
|
||||
signal(sig, SIG_DFL)
|
||||
raise(sig)
|
||||
}
|
||||
_ = prev
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
/// 漏斗追踪器
|
||||
final class FunnelTracker {
|
||||
|
||||
static let shared = FunnelTracker()
|
||||
|
||||
private var sessions: [String: [String]] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// 追踪事件
|
||||
func track(name: String, properties: [String: Any?]) {
|
||||
// 简化实现,后续可扩展
|
||||
}
|
||||
|
||||
/// 获取当前会话的事件序列
|
||||
func getSessionEvents(sessionId: String) -> [String] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return sessions[sessionId] ?? []
|
||||
}
|
||||
|
||||
/// 清除会话
|
||||
func clearSession(_ sessionId: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
sessions.removeValue(forKey: sessionId)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
|
||||
/// 日志队列 - 内存队列 + 定时上传
|
||||
final class LogQueue {
|
||||
|
||||
private let appKey: String
|
||||
private let sdkName: String
|
||||
private let sdkVersion: String
|
||||
private let uploader: LogUploader
|
||||
private let storage: LogStorage
|
||||
|
||||
private var queue: [Any] = []
|
||||
private let queueLock = NSLock()
|
||||
private var timer: Timer?
|
||||
private let flushInterval: TimeInterval = 30
|
||||
private let maxQueueSize = 50
|
||||
|
||||
init(appKey: String, sdkName: String, sdkVersion: String) {
|
||||
self.appKey = appKey
|
||||
self.sdkName = sdkName
|
||||
self.sdkVersion = sdkVersion
|
||||
self.uploader = LogUploader(appKey: appKey)
|
||||
self.storage = LogStorage(appKey: appKey)
|
||||
}
|
||||
|
||||
/// 启动定时上传
|
||||
func start() {
|
||||
// 上传待处理的崩溃
|
||||
uploadPendingCrashes()
|
||||
|
||||
// 启动定时器
|
||||
timer = Timer.scheduledTimer(withTimeInterval: flushInterval, repeats: true) { [weak self] _ in
|
||||
self?.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止队列
|
||||
func stop() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
flush()
|
||||
}
|
||||
|
||||
/// 推送事件
|
||||
func push(_ event: Any) {
|
||||
queueLock.lock()
|
||||
queue.append(event)
|
||||
let count = queue.count
|
||||
queueLock.unlock()
|
||||
|
||||
// 达到阈值时自动上传
|
||||
if count >= maxQueueSize {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新队列
|
||||
private func flush() {
|
||||
queueLock.lock()
|
||||
let events = queue
|
||||
queue = []
|
||||
queueLock.unlock()
|
||||
|
||||
guard !events.isEmpty else { return }
|
||||
|
||||
// 分离 issues 和 events
|
||||
var issues: [IssueEvent] = []
|
||||
var logEvents: [LogEvent] = []
|
||||
|
||||
for event in events {
|
||||
if let issue = event as? IssueEvent {
|
||||
issues.append(issue)
|
||||
} else if let logEvent = event as? LogEvent {
|
||||
logEvents.append(logEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传 issues
|
||||
if !issues.isEmpty {
|
||||
uploader.uploadIssues(issues)
|
||||
}
|
||||
|
||||
// 上传 events
|
||||
if !logEvents.isEmpty {
|
||||
uploader.uploadEvents(logEvents)
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传待处理的崩溃
|
||||
private func uploadPendingCrashes() {
|
||||
let crashes = storage.loadPendingCrashes()
|
||||
guard !crashes.isEmpty else { return }
|
||||
|
||||
uploader.uploadIssues(crashes) { [weak self] success in
|
||||
if success {
|
||||
self?.storage.clearPendingCrashes()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
/// 崩溃文件持久化
|
||||
enum LogStorage {
|
||||
|
||||
private static let crashDir = "xuqm_bugcollect_crashes"
|
||||
private static let encoder: JSONEncoder = {
|
||||
let e = JSONEncoder()
|
||||
e.dateEncodingStrategy = .iso8601
|
||||
return e
|
||||
}()
|
||||
|
||||
/// 保存崩溃到磁盘
|
||||
static func saveCrash(_ event: IssueEvent) {
|
||||
let dir = crashDirectory()
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
let filename = "\(Int(Date().timeIntervalSince1970))_\(event.fingerprint.prefix(8)).json"
|
||||
let url = dir.appendingPathComponent(filename)
|
||||
|
||||
if let data = try? encoder.encode(event) {
|
||||
try? data.write(to: url)
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载待处理的崩溃
|
||||
static func loadPendingCrashes() -> [IssueEvent] {
|
||||
let dir = crashDirectory()
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: nil
|
||||
) else { return [] }
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
|
||||
return files
|
||||
.filter { $0.pathExtension == "json" }
|
||||
.compactMap { url -> IssueEvent? in
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return try? decoder.decode(IssueEvent.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除已上传的崩溃
|
||||
static func clearPendingCrashes() {
|
||||
let dir = crashDirectory()
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: nil
|
||||
) else { return }
|
||||
|
||||
for file in files {
|
||||
try? FileManager.default.removeItem(at: file)
|
||||
}
|
||||
}
|
||||
|
||||
private static func crashDirectory() -> URL {
|
||||
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
||||
return caches.appendingPathComponent(crashDir)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
import XuqmCoreSDK
|
||||
|
||||
/// 日志上传器
|
||||
final class LogUploader {
|
||||
|
||||
private let appKey: String
|
||||
private let encoder: JSONEncoder = {
|
||||
let e = JSONEncoder()
|
||||
e.dateEncodingStrategy = .iso8601
|
||||
return e
|
||||
}()
|
||||
|
||||
init(appKey: String) {
|
||||
self.appKey = appKey
|
||||
}
|
||||
|
||||
/// 上传 Issue 事件
|
||||
func uploadIssues(_ issues: [IssueEvent], completion: ((Bool) -> Void)? = nil) {
|
||||
let baseURL = SDKEndpoints.bugCollectApiURL ?? SDKEndpoints.apiBaseURL
|
||||
guard let url = URL(string: "\(baseURL)/bugcollect/v1/issues/batch") else {
|
||||
completion?(false)
|
||||
return
|
||||
}
|
||||
|
||||
let body: [String: Any] = [
|
||||
"sentAt": ISO8601DateFormatter().string(from: Date()),
|
||||
"sdk": ["name": "bugcollect.ios", "version": "1.0.0"],
|
||||
"events": issues.map { issue in
|
||||
var event: [String: Any] = [
|
||||
"type": "issue",
|
||||
"level": issue.level,
|
||||
"platform": issue.platform,
|
||||
"fingerprint": issue.fingerprint,
|
||||
"appKey": issue.appKey,
|
||||
"release": issue.release,
|
||||
"environment": issue.environment
|
||||
]
|
||||
if let exception = issue.exception {
|
||||
event["exception"] = [
|
||||
"type": exception.type,
|
||||
"value": exception.value,
|
||||
"stacktrace": exception.stacktrace ?? ""
|
||||
]
|
||||
}
|
||||
if let userId = issue.userId {
|
||||
event["userId"] = userId
|
||||
}
|
||||
if let user = issue.user {
|
||||
event["user"] = ["id": user.id]
|
||||
}
|
||||
if let device = issue.device {
|
||||
event["device"] = [
|
||||
"model": device.model,
|
||||
"manufacturer": device.manufacturer,
|
||||
"osName": device.osName,
|
||||
"osVersion": device.osVersion,
|
||||
"locale": device.locale,
|
||||
"timezone": device.timezone,
|
||||
"isEmulator": device.isEmulator,
|
||||
"freeMemoryMb": device.freeMemoryMb,
|
||||
"buildType": device.buildType
|
||||
]
|
||||
}
|
||||
return event
|
||||
}
|
||||
]
|
||||
|
||||
upload(url: url, body: body, completion: completion)
|
||||
}
|
||||
|
||||
/// 上传日志事件
|
||||
func uploadEvents(_ events: [LogEvent], completion: ((Bool) -> Void)? = nil) {
|
||||
let baseURL = SDKEndpoints.bugCollectApiURL ?? SDKEndpoints.apiBaseURL
|
||||
guard let url = URL(string: "\(baseURL)/bugcollect/v1/events/batch") else {
|
||||
completion?(false)
|
||||
return
|
||||
}
|
||||
|
||||
let body: [String: Any] = [
|
||||
"sentAt": ISO8601DateFormatter().string(from: Date()),
|
||||
"sdk": ["name": "bugcollect.ios", "version": "1.0.0"],
|
||||
"events": events.map { event in
|
||||
var dict: [String: Any] = [
|
||||
"type": "event",
|
||||
"name": event.name,
|
||||
"appKey": event.appKey,
|
||||
"userId": event.userId,
|
||||
"platform": event.platform,
|
||||
"release": event.release,
|
||||
"environment": event.environment
|
||||
]
|
||||
if let props = event.properties {
|
||||
dict["properties"] = props.mapValues { $0.value }
|
||||
}
|
||||
return dict
|
||||
}
|
||||
]
|
||||
|
||||
upload(url: url, body: body, completion: completion)
|
||||
}
|
||||
|
||||
private func upload(url: URL, body: [String: Any], completion: ((Bool) -> Void)?) {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
// 添加认证 token
|
||||
if let token = XuqmSDK.shared.token {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
do {
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
} catch {
|
||||
completion?(false)
|
||||
return
|
||||
}
|
||||
|
||||
URLSession.shared.dataTask(with: request) { _, response, error in
|
||||
let success = error == nil &&
|
||||
(response as? HTTPURLResponse)?.statusCode ?? 0 < 400
|
||||
completion?(success)
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
@ -33,6 +33,7 @@ public extension SDKConfig {
|
||||
public enum SDKEndpoints {
|
||||
private static var _apiBaseURL: URL?
|
||||
private static var _imWebSocketURL: URL?
|
||||
private static var _bugCollectApiURL: URL?
|
||||
|
||||
public static var apiBaseURL: URL {
|
||||
_apiBaseURL ?? URL(string: "https://dev.xuqinmin.com")!
|
||||
@ -42,9 +43,14 @@ public enum SDKEndpoints {
|
||||
_imWebSocketURL ?? URL(string: "wss://dev.xuqinmin.com/ws/im")!
|
||||
}
|
||||
|
||||
public static var bugCollectApiURL: URL? {
|
||||
_bugCollectApiURL ?? _apiBaseURL
|
||||
}
|
||||
|
||||
public static func configure(
|
||||
apiBaseURL: String? = nil,
|
||||
imWebSocketURL: String? = nil
|
||||
imWebSocketURL: String? = nil,
|
||||
bugCollectApiURL: String? = nil
|
||||
) {
|
||||
if let api = apiBaseURL, let url = URL(string: api) {
|
||||
_apiBaseURL = url
|
||||
@ -52,10 +58,14 @@ public enum SDKEndpoints {
|
||||
if let ws = imWebSocketURL, let url = URL(string: ws) {
|
||||
_imWebSocketURL = url
|
||||
}
|
||||
if let bc = bugCollectApiURL, let url = URL(string: bc) {
|
||||
_bugCollectApiURL = url
|
||||
}
|
||||
}
|
||||
|
||||
public static func reset() {
|
||||
_apiBaseURL = nil
|
||||
_imWebSocketURL = nil
|
||||
_bugCollectApiURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
@_exported import XuqmUpdateSDK
|
||||
@_exported import XuqmLicenseSDK
|
||||
@_exported import XuqmFileSDK
|
||||
@_exported import XuqmBugCollectSDK
|
||||
|
||||
import Foundation
|
||||
|
||||
@ -15,13 +16,16 @@ extension XuqmSDK {
|
||||
public func registerModuleHooks() {
|
||||
// Note: licenseReader hook removed — autoInitialize now reads config.xuqm directly via ConfigFileReader.
|
||||
|
||||
// Initialize hook — auto-start push if configured
|
||||
// Initialize hook — auto-start push and bugcollect if configured
|
||||
onInitialize = { @MainActor config in
|
||||
if config.autoRegisterPush {
|
||||
Task { @MainActor in
|
||||
try? await PushSDK.shared.start()
|
||||
}
|
||||
}
|
||||
// 启动 BugCollect 崩溃捕获
|
||||
BugCollectSDK.shared.startCrashCapture()
|
||||
BugCollectSDK.shared.startQueue()
|
||||
}
|
||||
|
||||
// Login hook — connect IM and register push token
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户