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>
这个提交包含在:
XuqmGroup 2026-06-19 03:38:22 +08:00
父节点 ae9d033c85
当前提交 227bc66bfd
共有 11 个文件被更改,包括 963 次插入10 次删除

查看文件

@ -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