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>
这个提交包含在:
父节点
76099d897e
当前提交
d6c42ecf56
@ -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<string, unknown>
|
||||
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<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 {
|
||||
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<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 ────────────────────────────────────────────────────
|
||||
|
||||
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<BreadcrumbItem, 'timestamp'> & { 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<string, unknown>): 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<string, unknown>): 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,
|
||||
_enqueue(_buildIssue('error', error, metadata))
|
||||
},
|
||||
userId: getUserId() ?? undefined,
|
||||
sessionId: _sessionId,
|
||||
tags: metadata,
|
||||
sdk: { name: 'bugcollect.rn', version: '0.2.0' },
|
||||
}
|
||||
_enqueue(issue)
|
||||
|
||||
/** Upload a JS exception as a fatal-level issue (persisted across restarts). */
|
||||
captureFatal(error: unknown, metadata?: Record<string, unknown>): 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<void> {
|
||||
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 = []
|
||||
},
|
||||
}
|
||||
|
||||
@ -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<typeof setInterval> | null = null
|
||||
private retryCount = 0
|
||||
|
||||
@ -18,18 +26,29 @@ export class LogQueue {
|
||||
}
|
||||
|
||||
async push(event: BugCollectEvent): Promise<void> {
|
||||
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<void> {
|
||||
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<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}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -70,12 +96,16 @@ export class LogQueue {
|
||||
}
|
||||
}
|
||||
|
||||
private async _read(): Promise<BugCollectEvent[]> {
|
||||
const raw = await AsyncStorage.getItem(QUEUE_KEY)
|
||||
private async _readStored(): Promise<BugCollectEvent[]> {
|
||||
const raw = await AsyncStorage.getItem(STORED_QUEUE_KEY)
|
||||
return raw ? (JSON.parse(raw) as BugCollectEvent[]) : []
|
||||
}
|
||||
|
||||
private async _write(queue: BugCollectEvent[]): Promise<void> {
|
||||
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue))
|
||||
private async _writeStored(queue: BugCollectEvent[]): Promise<void> {
|
||||
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 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 {
|
||||
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
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户