fix(bugcollect-rn): 修复 BugCollect-API-v1-Review RN SDK 问题

- 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 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-18 09:26:26 +08:00
父节点 76099d897e
当前提交 d6c42ecf56
共有 3 个文件被更改,包括 160 次插入69 次删除

查看文件

@ -1,40 +1,75 @@
import { Platform } from 'react-native' import { Platform, Dimensions } from 'react-native'
import { getConfig, isInitialized, getUserId } from '@xuqm/rn-common' import { getConfig, isInitialized, getUserId } from '@xuqm/rn-common'
import { LogQueue } from './queue/LogQueue' import { LogQueue } from './queue/LogQueue'
import { ErrorCapture } from './capture/ErrorCapture' import { ErrorCapture } from './capture/ErrorCapture'
import { computeFingerprint } from './fingerprint' import { computeFingerprint } from './fingerprint'
import { FunnelTracker } from './funnel/FunnelTracker' 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 ─────────────────────────────────────────────────────────── // ─── Internal state ───────────────────────────────────────────────────────────
const SDK_NAME = 'bugcollect.rn'
const SDK_VERSION = '0.2.0'
const MAX_BREADCRUMBS = 100
let _logLevel: LogLevel = 'warn' let _logLevel: LogLevel = 'warn'
let _environment: Environment = 'production' let _environment: Environment = 'production'
let _queue: LogQueue | null = null let _queue: LogQueue | null = null
let _errorCaptureStarted = false let _errorCaptureStarted = false
let _breadcrumbs: BreadcrumbItem[] = []
// Stable session id for the lifetime of the JS runtime
const _sessionId: string = _generateSessionId() const _sessionId: string = _generateSessionId()
function _generateSessionId(): string { 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 { function _levelNum(level: LogLevel): number {
return { debug: 0, info: 1, warn: 2, error: 3 }[level] return { debug: 0, info: 1, warn: 2, error: 3 }[level]
} }
function _getPlatform(): 'ios' | 'android' { function _getPlatform(): 'ios' | 'android' | 'harmonyos' | 'web' | 'react-native' {
return Platform.OS === 'ios' ? 'ios' : 'android' 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 { function _getAppVersion(): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const constants = Platform.constants as Record<string, unknown> const constants = Platform.constants as Record<string, unknown>
const v = constants['appVersion'] ?? constants['Version'] const v = constants['appVersion'] ?? constants['Version']
return typeof v === 'string' ? v : String(v ?? '0.0.0') return typeof v === 'string' ? v : String(v ?? '0.0.0')
} }
function _getDeviceInfo(): DeviceInfo {
const constants = Platform.constants as Record<string, unknown>
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 { function _enqueue(event: BugCollectEvent): void {
if (!_queue) { if (!_queue) {
const cfg = getConfig() const cfg = getConfig()
@ -44,23 +79,50 @@ function _enqueue(event: BugCollectEvent): void {
void _queue.push(event) void _queue.push(event)
} }
function _buildIssue(level: Level, error: unknown, metadata?: Record<string, unknown>): 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 ──────────────────────────────────────────────────── // ─── BugCollect public API ────────────────────────────────────────────────────
export const BugCollect = { export const BugCollect = {
/** Set the minimum log level. Events below this level are discarded. */
setLogLevel(level: LogLevel): void { setLogLevel(level: LogLevel): void {
_logLevel = level _logLevel = level
}, },
/** Set an environment tag attached to every log event. */
setEnvironment(env: Environment): void { setEnvironment(env: Environment): void {
_environment = env _environment = env
}, },
/** /** Add a breadcrumb to the in-memory circular buffer (max 100). */
* Record a custom analytics event (funnel analysis, behaviour tracking). addBreadcrumb(item: Omit<BreadcrumbItem, 'timestamp'> & { timestamp?: number }): void {
* Also automatically advances any matching funnel step. 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<string, unknown>): void { event(name: string, properties?: Record<string, unknown>): void {
if (!isInitialized()) return if (!isInitialized()) return
const event: LogEvent = { const event: LogEvent = {
@ -74,36 +136,23 @@ export const BugCollect = {
platform: _getPlatform(), platform: _getPlatform(),
release: _getAppVersion(), release: _getAppVersion(),
environment: _environment, environment: _environment,
sdk: { name: 'bugcollect.rn', version: '0.2.0' }, device: _getDeviceInfo(),
sdk: { name: SDK_NAME, version: SDK_VERSION },
} }
_enqueue(event) _enqueue(event)
FunnelTracker.track(name, properties) 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<string, unknown>): void { captureError(error: unknown, metadata?: Record<string, unknown>): void {
if (!isInitialized()) return if (!isInitialized()) return
const err = error instanceof Error ? error : new Error(String(error)) _enqueue(_buildIssue('error', error, metadata))
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, /** Upload a JS exception as a fatal-level issue (persisted across restarts). */
tags: metadata, captureFatal(error: unknown, metadata?: Record<string, unknown>): void {
sdk: { name: 'bugcollect.rn', version: '0.2.0' }, if (!isInitialized()) return
} _enqueue(_buildIssue('fatal', error, metadata))
_enqueue(issue)
}, },
/** Log a warning (if log level allows). */ /** Log a warning (if log level allows). */
@ -119,14 +168,12 @@ export const BugCollect = {
appKey: getConfig().appKey, appKey: getConfig().appKey,
release: _getAppVersion(), release: _getAppVersion(),
environment: _environment, environment: _environment,
exception: { exception: { type: 'Warning', value: message },
type: 'Warning', breadcrumbs: _breadcrumbs.length > 0 ? [..._breadcrumbs] : undefined,
value: message,
},
userId: getUserId() ?? undefined, userId: getUserId() ?? undefined,
sessionId: _sessionId, sessionId: _sessionId,
tags: metadata, tags: metadata,
sdk: { name: 'bugcollect.rn', version: '0.2.0' }, sdk: { name: SDK_NAME, version: SDK_VERSION },
} }
_enqueue(issue) _enqueue(issue)
}, },
@ -144,14 +191,11 @@ export const BugCollect = {
appKey: getConfig().appKey, appKey: getConfig().appKey,
release: _getAppVersion(), release: _getAppVersion(),
environment: _environment, environment: _environment,
exception: { exception: { type: 'Info', value: message },
type: 'Info',
value: message,
},
userId: getUserId() ?? undefined, userId: getUserId() ?? undefined,
sessionId: _sessionId, sessionId: _sessionId,
tags: metadata, tags: metadata,
sdk: { name: 'bugcollect.rn', version: '0.2.0' }, sdk: { name: SDK_NAME, version: SDK_VERSION },
} }
_enqueue(issue) _enqueue(issue)
}, },
@ -166,22 +210,18 @@ export const BugCollect = {
ErrorCapture.start(BugCollect.captureError.bind(BugCollect)) ErrorCapture.start(BugCollect.captureError.bind(BugCollect))
}, },
/** Define a funnel for step-by-step conversion tracking. */
defineFunnel: FunnelTracker.define.bind(FunnelTracker), defineFunnel: FunnelTracker.define.bind(FunnelTracker),
/** Get client-side funnel progress (server aggregates across sessions). */
getFunnelProgress: FunnelTracker.getProgress.bind(FunnelTracker), getFunnelProgress: FunnelTracker.getProgress.bind(FunnelTracker),
/** Flush any pending events immediately (e.g. before app goes to background). */
async flush(): Promise<void> { async flush(): Promise<void> {
if (_queue) await _queue.flush() if (_queue) await _queue.flush()
}, },
/** Clean up resources (timers, etc.). Call on app termination if needed. */
destroy(): void { destroy(): void {
if (_queue) { if (_queue) {
_queue.destroy() _queue.destroy()
_queue = null _queue = null
} }
_breadcrumbs = []
}, },
} }

查看文件

@ -1,13 +1,21 @@
import AsyncStorage from '@react-native-async-storage/async-storage' 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 BATCH_SIZE = 30
const FLUSH_INTERVAL_MS = 10_000 // 10 seconds const FLUSH_INTERVAL_MS = 10_000 // 10 seconds
const MAX_QUEUE_SIZE = 500 const MAX_QUEUE_SIZE = 500
const MAX_RETRY = 3 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 { export class LogQueue {
private _memory: BugCollectEvent[] = []
private flushTimer: ReturnType<typeof setInterval> | null = null private flushTimer: ReturnType<typeof setInterval> | null = null
private retryCount = 0 private retryCount = 0
@ -18,18 +26,29 @@ export class LogQueue {
} }
async push(event: BugCollectEvent): Promise<void> { async push(event: BugCollectEvent): Promise<void> {
const queue = await this._read() if (shouldPersist(event)) {
if (queue.length >= MAX_QUEUE_SIZE) queue.shift() // drop oldest const stored = await this._readStored()
queue.push(event) if (stored.length >= MAX_QUEUE_SIZE) stored.shift()
await this._write(queue) 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<void> { async flush(): Promise<void> {
const queue = await this._read() const stored = await this._readStored()
if (queue.length === 0) return const combined = [...stored, ...this._memory]
if (combined.length === 0) return
const batch = queue.splice(0, BATCH_SIZE) const batch = combined.slice(0, BATCH_SIZE)
await this._write(queue) // remove from queue first to prevent duplicate sends 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 issues = batch.filter(e => e.type !== 'event')
const events = batch.filter(e => e.type === 'event') const events = batch.filter(e => e.type === 'event')
@ -41,10 +60,13 @@ export class LogQueue {
} catch { } catch {
this.retryCount++ this.retryCount++
if (this.retryCount < MAX_RETRY) { if (this.retryCount < MAX_RETRY) {
const current = await this._read() // Re-queue only the persistent subset (re-queuing memory events would inflate duplicates)
await this._write([...batch, ...current]) 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<void> { private async _post(path: string, data: BugCollectEvent[]): Promise<void> {
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}`, { const res = await fetch(`${this.cfg.bugCollectApiUrl}${path}`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -70,12 +96,16 @@ export class LogQueue {
} }
} }
private async _read(): Promise<BugCollectEvent[]> { private async _readStored(): Promise<BugCollectEvent[]> {
const raw = await AsyncStorage.getItem(QUEUE_KEY) const raw = await AsyncStorage.getItem(STORED_QUEUE_KEY)
return raw ? (JSON.parse(raw) as BugCollectEvent[]) : [] return raw ? (JSON.parse(raw) as BugCollectEvent[]) : []
} }
private async _write(queue: BugCollectEvent[]): Promise<void> { private async _writeStored(queue: BugCollectEvent[]): Promise<void> {
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue)) if (queue.length === 0) {
await AsyncStorage.removeItem(STORED_QUEUE_KEY)
} else {
await AsyncStorage.setItem(STORED_QUEUE_KEY, JSON.stringify(queue))
}
} }
} }

查看文件

@ -3,6 +3,17 @@ export type Environment = 'development' | 'staging' | 'production'
export type Level = 'fatal' | 'error' | 'warning' | 'info' | 'debug' export type Level = 'fatal' | 'error' | 'warning' | 'info' | 'debug'
export type Platform = 'ios' | 'android' | 'harmonyos' | 'web' | 'react-native' 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<string, unknown>
level?: Level
timestamp: number
}
export interface SdkInfo { export interface SdkInfo {
name: string name: string
version: string version: string
@ -16,14 +27,23 @@ export interface ExceptionInfo {
export interface UserInfo { export interface UserInfo {
id?: string id?: string
username?: string
email?: string
} }
export interface DeviceInfo { export interface DeviceInfo {
name?: string name?: string
model?: string model?: string
manufacturer?: string manufacturer?: string
brand?: string
osName?: string osName?: string
osVersion?: string osVersion?: string
sdkVersion?: string
screenWidth?: number
screenHeight?: number
locale?: string
timezone?: string
isEmulator?: boolean
} }
export interface LogEvent { export interface LogEvent {
@ -52,6 +72,7 @@ export interface IssueEvent {
release: string release: string
environment?: Environment environment?: Environment
exception?: ExceptionInfo exception?: ExceptionInfo
breadcrumbs?: BreadcrumbItem[]
userId?: string userId?: string
sessionId?: string sessionId?: string
user?: UserInfo user?: UserInfo