From 65e7a3e2c3df7f2d8e04dccca10394964a1d42fe Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 19 Jun 2026 15:10:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=B8=BF=E8=92=99=20BugCollect=20?= =?UTF-8?q?=E5=B4=A9=E6=BA=83=E6=94=B6=E9=9B=86=E6=A8=A1=E5=9D=97=E6=96=B0?= =?UTF-8?q?=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增模块:bugcollect/ - BugCollect.ets - 主入口,错误捕获 API - BugCollectTypes.ets - 数据模型 - Fingerprint.ets - 错误指纹计算(SHA-256) - LogQueue.ets - 内存队列 + 定时上传 功能: - captureError/captureCrash 捕获错误 - addBreadcrumb 添加面包屑 - event 记录自定义事件 - warn/info 日志级别记录 - 自动初始化(全局错误处理器) Co-Authored-By: Claude --- xuqm-sdk/Index.ets | 1 + .../src/main/ets/bugcollect/BugCollect.ets | 261 ++++++++++++++++++ .../main/ets/bugcollect/BugCollectTypes.ets | 86 ++++++ .../src/main/ets/bugcollect/Fingerprint.ets | 59 ++++ xuqm-sdk/src/main/ets/bugcollect/LogQueue.ets | 141 ++++++++++ 5 files changed, 548 insertions(+) create mode 100644 xuqm-sdk/src/main/ets/bugcollect/BugCollect.ets create mode 100644 xuqm-sdk/src/main/ets/bugcollect/BugCollectTypes.ets create mode 100644 xuqm-sdk/src/main/ets/bugcollect/Fingerprint.ets create mode 100644 xuqm-sdk/src/main/ets/bugcollect/LogQueue.ets diff --git a/xuqm-sdk/Index.ets b/xuqm-sdk/Index.ets index 7841341..433cfd3 100644 --- a/xuqm-sdk/Index.ets +++ b/xuqm-sdk/Index.ets @@ -6,6 +6,7 @@ export { SDKContext } from './src/main/ets/core/SDKContext' export { HttpClient } from './src/main/ets/core/HttpClient' export { XWebViewComponent, XWebViewBuilder } from './src/main/ets/xwebview/XWebView' export { XWebViewBridge, openXWebView, getXWebViewControl, setXWebViewController, getXWebViewConfig } from './src/main/ets/xwebview/XWebViewBridge' +export { BugCollect } from './src/main/ets/bugcollect/BugCollect' export type { ConfigFile, SDKConfig, diff --git a/xuqm-sdk/src/main/ets/bugcollect/BugCollect.ets b/xuqm-sdk/src/main/ets/bugcollect/BugCollect.ets new file mode 100644 index 0000000..43a41df --- /dev/null +++ b/xuqm-sdk/src/main/ets/bugcollect/BugCollect.ets @@ -0,0 +1,261 @@ +/** + * BugCollect 崩溃收集 SDK + */ + +import deviceInfo from '@ohos.deviceInfo' +import bundleManager from '@ohos.bundle.bundleManager' +import { SDKContext } from '../core/SDKContext' +import { computeFingerprint } from './Fingerprint' +import { LogQueue } from './LogQueue' +import type { IssueEvent, LogEvent, Breadcrumb, DeviceInfo, SdkInfo, LogLevel } from './BugCollectTypes' + +const SDK_NAME = 'bugcollect.harmony' +const SDK_VERSION = '1.0.0' +const MAX_BREADCRUMBS = 50 + +export class BugCollect { + private static logLevel: LogLevel = 2 // WARN + private static environment: string = 'production' + private static queue: LogQueue | null = null + private static breadcrumbs: Breadcrumb[] = [] + private static pendingErrors: IssueEvent[] = [] + private static initialized = false + + /** + * 设置日志级别 + */ + static setLogLevel(level: LogLevel): void { + BugCollect.logLevel = level + } + + /** + * 设置环境 + */ + static setEnvironment(env: string): void { + BugCollect.environment = env + } + + /** + * 添加面包屑 + */ + static addBreadcrumb(category: string, message: string, level: string = 'info', data?: Record): void { + if (BugCollect.breadcrumbs.length >= MAX_BREADCRUMBS) { + BugCollect.breadcrumbs.shift() + } + BugCollect.breadcrumbs.push({ + timestamp: Date.now(), + category, + message, + level, + data, + }) + } + + /** + * 记录事件 + */ + static event(name: string, properties?: Record): void { + if (!BugCollect.initialized) return + const config = SDKContext.getConfig() + + const logEvent: LogEvent = { + type: 'event', + eventId: BugCollect.generateUuid(), + name, + properties, + appKey: config.appKey, + userId: SDKContext.getUserId(), + platform: 'harmony', + release: BugCollect.getAppVersion(), + environment: BugCollect.environment, + device: BugCollect.buildDeviceInfo(), + sdk: { name: SDK_NAME, version: SDK_VERSION }, + } + + BugCollect.queue?.push(logEvent) + } + + /** + * 捕获错误 + */ + static captureError(error: Error, tags?: Record, fingerprint?: string): void { + const issueEvent = BugCollect.buildIssueEvent('error', error, tags, fingerprint) + + if (!BugCollect.initialized) { + BugCollect.pendingErrors.push(issueEvent) + return + } + + BugCollect.flushPendingErrors() + BugCollect.queue?.push(issueEvent) + } + + /** + * 捕获致命错误 + */ + static captureCrash(error: Error, tags?: Record): void { + if (!BugCollect.initialized) return + BugCollect.queue?.push(BugCollect.buildIssueEvent('fatal', error, tags)) + } + + /** + * 记录警告 + */ + static warn(message: string, tags?: Record): void { + if (BugCollect.logLevel > 2) return + if (!BugCollect.initialized) return + + const config = SDKContext.getConfig() + const issue: IssueEvent = { + type: 'issue', + eventId: BugCollect.generateUuid(), + level: 'warning', + platform: 'harmony', + fingerprint: '', // will be computed async + appKey: config.appKey, + exception: { type: 'Warning', value: message }, + breadcrumbs: [...BugCollect.breadcrumbs], + release: BugCollect.getAppVersion(), + environment: BugCollect.environment, + userId: SDKContext.getUserId(), + user: { id: SDKContext.getUserId() ?? '' }, + device: BugCollect.buildDeviceInfo(), + tags, + sdk: { name: SDK_NAME, version: SDK_VERSION }, + } + + // Compute fingerprint async + computeFingerprint('Warning', message, '').then(fp => { + issue.fingerprint = fp + BugCollect.queue?.push(issue) + }) + } + + /** + * 记录信息 + */ + static info(message: string, tags?: Record): void { + if (BugCollect.logLevel > 1) return + BugCollect.event('__log_info', { ...tags, message }) + } + + /** + * 启动 + */ + static start(): void { + if (BugCollect.initialized) return + BugCollect.initialized = true + + const config = SDKContext.getConfig() + BugCollect.queue = new LogQueue(config.appKey, config.apiUrl) + + // 注册崩溃处理器 + BugCollect.registerCrashHandler() + + // 刷新待处理的错误 + BugCollect.flushPendingErrors() + } + + /** + * 注册崩溃处理器 + */ + private static registerCrashHandler(): void { + // HarmonyOS 错误处理 + try { + // 注册全局错误处理器 + ErrorUtils?.setGlobalHandler?.((error: Error) => { + BugCollect.captureCrash(error) + }) + } catch { + // ignore + } + } + + /** + * 刷新待处理的错误 + */ + private static flushPendingErrors(): void { + if (BugCollect.pendingErrors.length === 0) return + const errors = [...BugCollect.pendingErrors] + BugCollect.pendingErrors = [] + for (const error of errors) { + BugCollect.queue?.push(error) + } + } + + /** + * 构建 IssueEvent + */ + private static buildIssueEvent(level: IssueEvent['level'], error: Error, tags?: Record, fingerprint?: string): IssueEvent { + const config = SDKContext.getConfig() + const stacktrace = error.stack ?? '' + + const event: IssueEvent = { + type: 'issue', + eventId: BugCollect.generateUuid(), + level, + platform: 'harmony', + fingerprint: fingerprint ?? '', // will be computed async + appKey: config.appKey, + exception: { + type: error.name ?? 'Error', + value: error.message ?? '', + stacktrace, + }, + breadcrumbs: [...BugCollect.breadcrumbs], + release: BugCollect.getAppVersion(), + environment: BugCollect.environment, + userId: SDKContext.getUserId(), + user: { id: SDKContext.getUserId() ?? '' }, + device: BugCollect.buildDeviceInfo(), + tags, + sdk: { name: SDK_NAME, version: SDK_VERSION }, + } + + // Compute fingerprint async + if (!fingerprint) { + computeFingerprint(error.name ?? 'Error', error.message ?? '', stacktrace).then(fp => { + event.fingerprint = fp + }) + } + + return event + } + + /** + * 构建设备信息 + */ + private static buildDeviceInfo(): DeviceInfo { + return { + model: deviceInfo.product ?? '', + manufacturer: deviceInfo.brand ?? '', + osName: 'HarmonyOS', + osVersion: deviceInfo.osReleaseType ?? '', + locale: deviceInfo.locale ?? '', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + } + } + + /** + * 获取应用版本 + */ + private static getAppVersion(): string { + try { + const bundleInfo = bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT) + return bundleInfo.versionName + } catch { + return '1.0.0' + } + } + + /** + * 生成 UUID + */ + private static generateUuid(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.floor(Math.random() * 16) + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) + } +} diff --git a/xuqm-sdk/src/main/ets/bugcollect/BugCollectTypes.ets b/xuqm-sdk/src/main/ets/bugcollect/BugCollectTypes.ets new file mode 100644 index 0000000..914b798 --- /dev/null +++ b/xuqm-sdk/src/main/ets/bugcollect/BugCollectTypes.ets @@ -0,0 +1,86 @@ +/** + * BugCollect 类型定义 + */ + +/// Issue 事件 +export interface IssueEvent { + type: 'issue' + eventId: string + level: 'fatal' | 'error' | 'warning' | 'info' | 'debug' + platform: string + fingerprint: string + appKey: string + exception?: ExceptionInfo + breadcrumbs?: Breadcrumb[] + release: string + environment: string + userId?: string + user?: UserInfo + device?: DeviceInfo + tags?: Record + sdk?: SdkInfo +} + +/// 异常信息 +export interface ExceptionInfo { + type: string + value: string + stacktrace?: string +} + +/// 用户信息 +export interface UserInfo { + id: string +} + +/// 设备信息 +export interface DeviceInfo { + model?: string + manufacturer?: string + osName?: string + osVersion?: string + locale?: string + timezone?: string + isEmulator?: boolean + freeMemoryMb?: number + buildType?: string +} + +/// SDK 信息 +export interface SdkInfo { + name: string + version: string +} + +/// 面包屑 +export interface Breadcrumb { + timestamp: number + category: string + message: string + level: string + data?: Record +} + +/// 日志事件 +export interface LogEvent { + type: 'event' + eventId: string + name: string + properties?: Record + appKey: string + userId?: string + platform: string + release: string + environment: string + device?: DeviceInfo + sdk?: SdkInfo +} + +/// 日志级别 +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, + NONE = 4 +} diff --git a/xuqm-sdk/src/main/ets/bugcollect/Fingerprint.ets b/xuqm-sdk/src/main/ets/bugcollect/Fingerprint.ets new file mode 100644 index 0000000..d93f37b --- /dev/null +++ b/xuqm-sdk/src/main/ets/bugcollect/Fingerprint.ets @@ -0,0 +1,59 @@ +/** + * 错误指纹计算 + */ + +import cryptoFramework from '@ohos.security.cryptoFramework' + +/** + * 计算错误指纹 + */ +export async function computeFingerprint(type: string, message: string, stacktrace?: string): Promise { + const normalizedMessage = normalizeMessage(message) + const top3Frames = extractTop3Frames(stacktrace) + const raw = `${type}:${normalizedMessage}:${top3Frames}` + + try { + const md = cryptoFramework.createMd('SHA256') + const input = new Uint8Array(Buffer.from(raw, 'utf-8').buffer) + md.update({ data: input }) + const output = md.digest() + const hash = Buffer.from(output.data).toString('hex') + return hash + } catch { + // Fallback: simple hash + return simpleHash(raw) + } +} + +/** + * 归一化消息 + */ +function normalizeMessage(message: string): string { + return message + .replace(/\d+/g, 'N') + .replace(/[0-9a-fA-F]{8,}/g, 'H') +} + +/** + * 提取堆栈顶部帧 + */ +function extractTop3Frames(stacktrace?: string): string { + if (!stacktrace) return '' + return stacktrace + .split('\n') + .slice(0, 3) + .join('|') +} + +/** + * 简单哈希(fallback) + */ +function simpleHash(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash = hash & hash + } + return Math.abs(hash).toString(16) +} diff --git a/xuqm-sdk/src/main/ets/bugcollect/LogQueue.ets b/xuqm-sdk/src/main/ets/bugcollect/LogQueue.ets new file mode 100644 index 0000000..db62b23 --- /dev/null +++ b/xuqm-sdk/src/main/ets/bugcollect/LogQueue.ets @@ -0,0 +1,141 @@ +/** + * 日志队列 + */ + +import { HttpClient } from '../core/HttpClient' +import { SDKContext } from '../core/SDKContext' +import type { IssueEvent, LogEvent } from './BugCollectTypes' + +const BATCH_SIZE = 30 +const FLUSH_INTERVAL_MS = 10000 +const MAX_QUEUE_SIZE = 500 +const MAX_RETRY = 3 +const STORAGE_KEY = 'xuqm_bugcollect_queue' + +type Event = IssueEvent | LogEvent + +export class LogQueue { + private memory: Event[] = [] + private flushTimer: number = 0 + private retryCount = 0 + private appKey: string + private baseUrl: string + + constructor(appKey: string, baseUrl: string) { + this.appKey = appKey + this.baseUrl = baseUrl + this.flushTimer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS) + } + + /** + * 推送事件 + */ + async push(event: Event): Promise { + if (this.shouldPersist(event)) { + const stored = await this.readStored() + if (stored.length >= MAX_QUEUE_SIZE) stored.shift() + stored.push(event) + await this.writeStored(stored) + } else { + if (this.memory.length >= MAX_QUEUE_SIZE) this.memory.shift() + this.memory.push(event) + } + } + + /** + * 刷新队列 + */ + async flush(): Promise { + const stored = await this.readStored() + const combined = [...stored, ...this.memory] + if (combined.length === 0) return + + const batch = combined.slice(0, BATCH_SIZE) + const storedUsed = Math.min(stored.length, BATCH_SIZE) + const memUsed = batch.length - storedUsed + + // 乐观移除 + await this.writeStored(stored.slice(storedUsed)) + this.memory.splice(0, memUsed) + + const issues = batch.filter((e): e is IssueEvent => e.type === 'issue') + const events = batch.filter((e): e is LogEvent => e.type === 'event') + + try { + if (issues.length > 0) { + await this.post('/bugcollect/v1/issues/batch', issues) + } + if (events.length > 0) { + await this.post('/bugcollect/v1/events/batch', events) + } + this.retryCount = 0 + } catch { + this.retryCount++ + if (this.retryCount < MAX_RETRY) { + const toRequeue = batch.filter(e => this.shouldPersist(e)) + if (toRequeue.length > 0) { + const current = await this.readStored() + await this.writeStored([...toRequeue, ...current]) + } + } + } + } + + /** + * 销毁队列 + */ + destroy(): void { + if (this.flushTimer) { + clearInterval(this.flushTimer) + this.flushTimer = 0 + } + } + + /** + * 判断是否需要持久化 + */ + private shouldPersist(event: Event): boolean { + return event.type === 'issue' && (event.level === 'fatal' || event.level === 'error') + } + + /** + * 上传数据 + */ + private async post(path: string, data: Event[]): Promise { + const body = JSON.stringify({ + sentAt: new Date().toISOString(), + sdk: { name: 'bugcollect.harmony', version: '1.0.0' }, + events: data, + }) + + await HttpClient.post(path, body) + } + + /** + * 读取持久化队列 + */ + private async readStored(): Promise { + try { + const raw = await SDKContext.getPreference(STORAGE_KEY) + if (!raw) return [] + return JSON.parse(raw) as Event[] + } catch { + return [] + } + } + + /** + * 写入持久化队列 + */ + private async writeStored(queue: Event[]): Promise { + try { + if (queue.length === 0) { + await SDKContext.setPreference(STORAGE_KEY, '') + } else { + await SDKContext.setPreference(STORAGE_KEY, JSON.stringify(queue)) + } + } catch { + // ignore + } + } +}