From 227bc66bfd518a9f1697b7bf3fee975fb6c81b63 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 19 Jun 2026 03:38:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20iOS=20BugCollect?= =?UTF-8?q?=20=E5=B4=A9=E6=BA=83=E6=94=B6=E9=9B=86=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增模块: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 --- Package.swift | 24 +- Sources/XuqmBugCollectSDK/BugCollectSDK.swift | 290 ++++++++++++++++++ Sources/XuqmBugCollectSDK/Fingerprint.swift | 33 ++ Sources/XuqmBugCollectSDK/IssueEvent.swift | 228 ++++++++++++++ .../internal/CrashCapture.swift | 61 ++++ .../internal/FunnelTracker.swift | 31 ++ .../XuqmBugCollectSDK/internal/LogQueue.swift | 100 ++++++ .../internal/LogStorage.swift | 62 ++++ .../internal/LogUploader.swift | 126 ++++++++ Sources/XuqmCoreSDK/SDKConfig.swift | 12 +- Sources/XuqmSDK/XuqmModuleRegistration.swift | 6 +- 11 files changed, 963 insertions(+), 10 deletions(-) create mode 100644 Sources/XuqmBugCollectSDK/BugCollectSDK.swift create mode 100644 Sources/XuqmBugCollectSDK/Fingerprint.swift create mode 100644 Sources/XuqmBugCollectSDK/IssueEvent.swift create mode 100644 Sources/XuqmBugCollectSDK/internal/CrashCapture.swift create mode 100644 Sources/XuqmBugCollectSDK/internal/FunnelTracker.swift create mode 100644 Sources/XuqmBugCollectSDK/internal/LogQueue.swift create mode 100644 Sources/XuqmBugCollectSDK/internal/LogStorage.swift create mode 100644 Sources/XuqmBugCollectSDK/internal/LogUploader.swift diff --git a/Package.swift b/Package.swift index 43def58..ef57728 100644 --- a/Package.swift +++ b/Package.swift @@ -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")] diff --git a/Sources/XuqmBugCollectSDK/BugCollectSDK.swift b/Sources/XuqmBugCollectSDK/BugCollectSDK.swift new file mode 100644 index 0000000..115ea31 --- /dev/null +++ b/Sources/XuqmBugCollectSDK/BugCollectSDK.swift @@ -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(_ block: () -> T) -> T { + lock() + defer { unlock() } + return block() + } +} diff --git a/Sources/XuqmBugCollectSDK/Fingerprint.swift b/Sources/XuqmBugCollectSDK/Fingerprint.swift new file mode 100644 index 0000000..e57c468 --- /dev/null +++ b/Sources/XuqmBugCollectSDK/Fingerprint.swift @@ -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: "|") + } +} diff --git a/Sources/XuqmBugCollectSDK/IssueEvent.swift b/Sources/XuqmBugCollectSDK/IssueEvent.swift new file mode 100644 index 0000000..2e10484 --- /dev/null +++ b/Sources/XuqmBugCollectSDK/IssueEvent.swift @@ -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() + } + } +} diff --git a/Sources/XuqmBugCollectSDK/internal/CrashCapture.swift b/Sources/XuqmBugCollectSDK/internal/CrashCapture.swift new file mode 100644 index 0000000..e959c87 --- /dev/null +++ b/Sources/XuqmBugCollectSDK/internal/CrashCapture.swift @@ -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 + } + } +} diff --git a/Sources/XuqmBugCollectSDK/internal/FunnelTracker.swift b/Sources/XuqmBugCollectSDK/internal/FunnelTracker.swift new file mode 100644 index 0000000..f05277e --- /dev/null +++ b/Sources/XuqmBugCollectSDK/internal/FunnelTracker.swift @@ -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) + } +} diff --git a/Sources/XuqmBugCollectSDK/internal/LogQueue.swift b/Sources/XuqmBugCollectSDK/internal/LogQueue.swift new file mode 100644 index 0000000..dc63150 --- /dev/null +++ b/Sources/XuqmBugCollectSDK/internal/LogQueue.swift @@ -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() + } + } + } +} diff --git a/Sources/XuqmBugCollectSDK/internal/LogStorage.swift b/Sources/XuqmBugCollectSDK/internal/LogStorage.swift new file mode 100644 index 0000000..533af4d --- /dev/null +++ b/Sources/XuqmBugCollectSDK/internal/LogStorage.swift @@ -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) + } +} diff --git a/Sources/XuqmBugCollectSDK/internal/LogUploader.swift b/Sources/XuqmBugCollectSDK/internal/LogUploader.swift new file mode 100644 index 0000000..2654f54 --- /dev/null +++ b/Sources/XuqmBugCollectSDK/internal/LogUploader.swift @@ -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() + } +} diff --git a/Sources/XuqmCoreSDK/SDKConfig.swift b/Sources/XuqmCoreSDK/SDKConfig.swift index 1db7d03..0d18a7f 100644 --- a/Sources/XuqmCoreSDK/SDKConfig.swift +++ b/Sources/XuqmCoreSDK/SDKConfig.swift @@ -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 } } diff --git a/Sources/XuqmSDK/XuqmModuleRegistration.swift b/Sources/XuqmSDK/XuqmModuleRegistration.swift index 48659bd..1258fff 100644 --- a/Sources/XuqmSDK/XuqmModuleRegistration.swift +++ b/Sources/XuqmSDK/XuqmModuleRegistration.swift @@ -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