XuqmGroup-iOSSDK/Sources/XuqmBugCollectSDK/BugCollectSDK.swift
XuqmGroup 227bc66bfd 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>
2026-06-19 03:38:22 +08:00

291 行
9.1 KiB
Swift

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()
}
}