From d6c42ecf56f871168a93028da3fc9affc4054112 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Thu, 18 Jun 2026 09:26:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(bugcollect-rn):=20=E4=BF=AE=E5=A4=8D=20BugC?= =?UTF-8?q?ollect-API-v1-Review=20RN=20SDK=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: 新增 BreadcrumbItem 类型;DeviceInfo 补全 brand/sdkVersion/screen/locale/timezone/isEmulator;UserInfo 补全 username/email;IssueEvent 新增 breadcrumbs 字段 - BugCollect.ts: 新增 addBreadcrumb API;新增 captureFatal API;_getPlatform() 支持 harmonyos;_getDeviceInfo() 采集完整设备信息;版本号改为常量;sessionId 加长 - LogQueue.ts: 按 level 分级持久化——fatal/error 写 AsyncStorage(进程崩溃后仍可重传),其余仅内存;重试时只重新入队 fatal/error 事件 Co-Authored-By: Claude Sonnet 4.6 --- packages/bugcollect/src/BugCollect.ts | 142 ++++++++++++++-------- packages/bugcollect/src/queue/LogQueue.ts | 66 +++++++--- packages/bugcollect/src/types.ts | 21 ++++ 3 files changed, 160 insertions(+), 69 deletions(-) diff --git a/packages/bugcollect/src/BugCollect.ts b/packages/bugcollect/src/BugCollect.ts index e2519be..a0472b7 100644 --- a/packages/bugcollect/src/BugCollect.ts +++ b/packages/bugcollect/src/BugCollect.ts @@ -1,40 +1,75 @@ -import { Platform } from 'react-native' +import { Platform, Dimensions } from 'react-native' import { getConfig, isInitialized, getUserId } from '@xuqm/rn-common' import { LogQueue } from './queue/LogQueue' import { ErrorCapture } from './capture/ErrorCapture' import { computeFingerprint } from './fingerprint' import { FunnelTracker } from './funnel/FunnelTracker' -import type { LogLevel, Environment, IssueEvent, LogEvent, BugCollectEvent } from './types' +import type { + LogLevel, + Environment, + IssueEvent, + LogEvent, + BugCollectEvent, + BreadcrumbItem, + DeviceInfo, + Level, +} from './types' // ─── Internal state ─────────────────────────────────────────────────────────── +const SDK_NAME = 'bugcollect.rn' +const SDK_VERSION = '0.2.0' +const MAX_BREADCRUMBS = 100 + let _logLevel: LogLevel = 'warn' let _environment: Environment = 'production' let _queue: LogQueue | null = null let _errorCaptureStarted = false +let _breadcrumbs: BreadcrumbItem[] = [] -// Stable session id for the lifetime of the JS runtime const _sessionId: string = _generateSessionId() function _generateSessionId(): string { - return 'xxxx-xxxx'.replace(/x/g, () => ((Math.random() * 16) | 0).toString(16)) + return 'xxxx-xxxx-xxxx'.replace(/x/g, () => ((Math.random() * 16) | 0).toString(16)) } function _levelNum(level: LogLevel): number { return { debug: 0, info: 1, warn: 2, error: 3 }[level] } -function _getPlatform(): 'ios' | 'android' { - return Platform.OS === 'ios' ? 'ios' : 'android' +function _getPlatform(): 'ios' | 'android' | 'harmonyos' | 'web' | 'react-native' { + switch (Platform.OS) { + case 'ios': return 'ios' + case 'android': return 'android' + // @ts-expect-error HarmonyOS is not in the official RN types + case 'harmony': return 'harmonyos' + default: return 'react-native' + } } function _getAppVersion(): string { - // eslint-disable-next-line @typescript-eslint/no-explicit-any const constants = Platform.constants as Record const v = constants['appVersion'] ?? constants['Version'] return typeof v === 'string' ? v : String(v ?? '0.0.0') } +function _getDeviceInfo(): DeviceInfo { + const constants = Platform.constants as Record + const { width, height } = Dimensions.get('screen') + return { + model: typeof constants['Model'] === 'string' ? constants['Model'] : undefined, + brand: typeof constants['Brand'] === 'string' ? constants['Brand'] : undefined, + manufacturer: typeof constants['Manufacturer'] === 'string' ? constants['Manufacturer'] : undefined, + osName: Platform.OS, + osVersion: typeof Platform.Version === 'string' ? Platform.Version : String(Platform.Version), + sdkVersion: Platform.OS === 'android' && typeof constants['Release'] === 'string' + ? constants['Release'] + : undefined, + screenWidth: width, + screenHeight: height, + } +} + function _enqueue(event: BugCollectEvent): void { if (!_queue) { const cfg = getConfig() @@ -44,23 +79,50 @@ function _enqueue(event: BugCollectEvent): void { void _queue.push(event) } +function _buildIssue(level: Level, error: unknown, metadata?: Record): IssueEvent { + const err = error instanceof Error ? error : new Error(String(error)) + return { + type: 'issue', + level, + platform: _getPlatform(), + fingerprint: computeFingerprint(level, err.message, err.stack), + timestamp: Date.now(), + appKey: getConfig().appKey, + release: _getAppVersion(), + environment: _environment, + exception: { + type: err.name ?? 'Error', + value: err.message, + stacktrace: err.stack, + }, + breadcrumbs: _breadcrumbs.length > 0 ? [..._breadcrumbs] : undefined, + userId: getUserId() ?? undefined, + sessionId: _sessionId, + tags: metadata, + device: _getDeviceInfo(), + sdk: { name: SDK_NAME, version: SDK_VERSION }, + } +} + // ─── BugCollect public API ──────────────────────────────────────────────────── export const BugCollect = { - /** Set the minimum log level. Events below this level are discarded. */ setLogLevel(level: LogLevel): void { _logLevel = level }, - /** Set an environment tag attached to every log event. */ setEnvironment(env: Environment): void { _environment = env }, - /** - * Record a custom analytics event (funnel analysis, behaviour tracking). - * Also automatically advances any matching funnel step. - */ + /** Add a breadcrumb to the in-memory circular buffer (max 100). */ + addBreadcrumb(item: Omit & { timestamp?: number }): void { + const crumb: BreadcrumbItem = { ...item, timestamp: item.timestamp ?? Date.now() } + if (_breadcrumbs.length >= MAX_BREADCRUMBS) _breadcrumbs.shift() + _breadcrumbs.push(crumb) + }, + + /** Record a custom analytics event (funnel analysis, behaviour tracking). */ event(name: string, properties?: Record): void { if (!isInitialized()) return const event: LogEvent = { @@ -74,36 +136,23 @@ export const BugCollect = { platform: _getPlatform(), release: _getAppVersion(), environment: _environment, - sdk: { name: 'bugcollect.rn', version: '0.2.0' }, + device: _getDeviceInfo(), + sdk: { name: SDK_NAME, version: SDK_VERSION }, } _enqueue(event) FunnelTracker.track(name, properties) }, - /** Upload a JS exception to the log service. */ + /** Upload a JS exception as an error-level issue. */ captureError(error: unknown, metadata?: Record): void { if (!isInitialized()) return - const err = error instanceof Error ? error : new Error(String(error)) - const issue: IssueEvent = { - type: 'issue', - level: 'error', - platform: _getPlatform(), - fingerprint: computeFingerprint('error', err.message, err.stack), - timestamp: Date.now(), - appKey: getConfig().appKey, - release: _getAppVersion(), - environment: _environment, - exception: { - type: err.name ?? 'Error', - value: err.message, - stacktrace: err.stack, - }, - userId: getUserId() ?? undefined, - sessionId: _sessionId, - tags: metadata, - sdk: { name: 'bugcollect.rn', version: '0.2.0' }, - } - _enqueue(issue) + _enqueue(_buildIssue('error', error, metadata)) + }, + + /** Upload a JS exception as a fatal-level issue (persisted across restarts). */ + captureFatal(error: unknown, metadata?: Record): void { + if (!isInitialized()) return + _enqueue(_buildIssue('fatal', error, metadata)) }, /** Log a warning (if log level allows). */ @@ -119,14 +168,12 @@ export const BugCollect = { appKey: getConfig().appKey, release: _getAppVersion(), environment: _environment, - exception: { - type: 'Warning', - value: message, - }, + exception: { type: 'Warning', value: message }, + breadcrumbs: _breadcrumbs.length > 0 ? [..._breadcrumbs] : undefined, userId: getUserId() ?? undefined, sessionId: _sessionId, tags: metadata, - sdk: { name: 'bugcollect.rn', version: '0.2.0' }, + sdk: { name: SDK_NAME, version: SDK_VERSION }, } _enqueue(issue) }, @@ -144,14 +191,11 @@ export const BugCollect = { appKey: getConfig().appKey, release: _getAppVersion(), environment: _environment, - exception: { - type: 'Info', - value: message, - }, + exception: { type: 'Info', value: message }, userId: getUserId() ?? undefined, sessionId: _sessionId, tags: metadata, - sdk: { name: 'bugcollect.rn', version: '0.2.0' }, + sdk: { name: SDK_NAME, version: SDK_VERSION }, } _enqueue(issue) }, @@ -166,22 +210,18 @@ export const BugCollect = { ErrorCapture.start(BugCollect.captureError.bind(BugCollect)) }, - /** Define a funnel for step-by-step conversion tracking. */ defineFunnel: FunnelTracker.define.bind(FunnelTracker), - - /** Get client-side funnel progress (server aggregates across sessions). */ getFunnelProgress: FunnelTracker.getProgress.bind(FunnelTracker), - /** Flush any pending events immediately (e.g. before app goes to background). */ async flush(): Promise { if (_queue) await _queue.flush() }, - /** Clean up resources (timers, etc.). Call on app termination if needed. */ destroy(): void { if (_queue) { _queue.destroy() _queue = null } + _breadcrumbs = [] }, } diff --git a/packages/bugcollect/src/queue/LogQueue.ts b/packages/bugcollect/src/queue/LogQueue.ts index 7b14b95..da11bfc 100644 --- a/packages/bugcollect/src/queue/LogQueue.ts +++ b/packages/bugcollect/src/queue/LogQueue.ts @@ -1,13 +1,21 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import type { BugCollectEvent } from '../types' +import type { BugCollectEvent, IssueEvent } from '../types' -const QUEUE_KEY = '@xuqm_bugcollect:queue' +const STORED_QUEUE_KEY = '@xuqm_bugcollect:queue' const BATCH_SIZE = 30 const FLUSH_INTERVAL_MS = 10_000 // 10 seconds const MAX_QUEUE_SIZE = 500 const MAX_RETRY = 3 +/** Only fatal and error issues are persisted to AsyncStorage (survive restarts). */ +function shouldPersist(event: BugCollectEvent): boolean { + if (event.type !== 'issue') return false + const level = (event as IssueEvent).level + return level === 'fatal' || level === 'error' +} + export class LogQueue { + private _memory: BugCollectEvent[] = [] private flushTimer: ReturnType | null = null private retryCount = 0 @@ -18,18 +26,29 @@ export class LogQueue { } async push(event: BugCollectEvent): Promise { - const queue = await this._read() - if (queue.length >= MAX_QUEUE_SIZE) queue.shift() // drop oldest - queue.push(event) - await this._write(queue) + if (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 queue = await this._read() - if (queue.length === 0) return + const stored = await this._readStored() + const combined = [...stored, ...this._memory] + if (combined.length === 0) return - const batch = queue.splice(0, BATCH_SIZE) - await this._write(queue) // remove from queue first to prevent duplicate sends + const batch = combined.slice(0, BATCH_SIZE) + const storedUsed = Math.min(stored.length, BATCH_SIZE) + const memUsed = batch.length - storedUsed + + // Optimistically remove from sources before sending to avoid duplicates + await this._writeStored(stored.slice(storedUsed)) + this._memory = this._memory.slice(memUsed) const issues = batch.filter(e => e.type !== 'event') const events = batch.filter(e => e.type === 'event') @@ -41,10 +60,13 @@ export class LogQueue { } catch { this.retryCount++ if (this.retryCount < MAX_RETRY) { - const current = await this._read() - await this._write([...batch, ...current]) + // Re-queue only the persistent subset (re-queuing memory events would inflate duplicates) + const toRequeue = batch.filter(shouldPersist) + if (toRequeue.length > 0) { + const current = await this._readStored() + await this._writeStored([...toRequeue, ...current]) + } } - // 超过重试次数,丢弃该批次 } } @@ -56,7 +78,11 @@ export class LogQueue { } private async _post(path: string, data: BugCollectEvent[]): Promise { - const body = JSON.stringify({ sentAt: new Date().toISOString(), sdk: { name: 'bugcollect.rn', version: '0.2.0' }, events: data }) + const body = JSON.stringify({ + sentAt: new Date().toISOString(), + sdk: { name: 'bugcollect.rn', version: '0.2.0' }, + events: data, + }) const res = await fetch(`${this.cfg.bugCollectApiUrl}${path}`, { method: 'POST', headers: { @@ -70,12 +96,16 @@ export class LogQueue { } } - private async _read(): Promise { - const raw = await AsyncStorage.getItem(QUEUE_KEY) + private async _readStored(): Promise { + const raw = await AsyncStorage.getItem(STORED_QUEUE_KEY) return raw ? (JSON.parse(raw) as BugCollectEvent[]) : [] } - private async _write(queue: BugCollectEvent[]): Promise { - await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue)) + private async _writeStored(queue: BugCollectEvent[]): Promise { + if (queue.length === 0) { + await AsyncStorage.removeItem(STORED_QUEUE_KEY) + } else { + await AsyncStorage.setItem(STORED_QUEUE_KEY, JSON.stringify(queue)) + } } } diff --git a/packages/bugcollect/src/types.ts b/packages/bugcollect/src/types.ts index cc3e891..b700442 100644 --- a/packages/bugcollect/src/types.ts +++ b/packages/bugcollect/src/types.ts @@ -3,6 +3,17 @@ export type Environment = 'development' | 'staging' | 'production' export type Level = 'fatal' | 'error' | 'warning' | 'info' | 'debug' export type Platform = 'ios' | 'android' | 'harmonyos' | 'web' | 'react-native' +export type BreadcrumbType = 'navigation' | 'http' | 'user' | 'console' | 'error' | 'default' + +export interface BreadcrumbItem { + type: BreadcrumbType + category?: string + message?: string + data?: Record + level?: Level + timestamp: number +} + export interface SdkInfo { name: string version: string @@ -16,14 +27,23 @@ export interface ExceptionInfo { export interface UserInfo { id?: string + username?: string + email?: string } export interface DeviceInfo { name?: string model?: string manufacturer?: string + brand?: string osName?: string osVersion?: string + sdkVersion?: string + screenWidth?: number + screenHeight?: number + locale?: string + timezone?: string + isEmulator?: boolean } export interface LogEvent { @@ -52,6 +72,7 @@ export interface IssueEvent { release: string environment?: Environment exception?: ExceptionInfo + breadcrumbs?: BreadcrumbItem[] userId?: string sessionId?: string user?: UserInfo