2026-06-18 09:26:26 +08:00
|
|
|
|
import { Platform, Dimensions } from 'react-native'
|
2026-06-16 12:10:28 +08:00
|
|
|
|
import { getConfig, isInitialized, getUserId } from '@xuqm/rn-common'
|
|
|
|
|
|
import { LogQueue } from './queue/LogQueue'
|
|
|
|
|
|
import { ErrorCapture } from './capture/ErrorCapture'
|
2026-06-25 17:50:29 +08:00
|
|
|
|
import { HttpInterceptor } from './interceptor/HttpInterceptor'
|
2026-06-16 12:10:28 +08:00
|
|
|
|
import { computeFingerprint } from './fingerprint'
|
|
|
|
|
|
import { FunnelTracker } from './funnel/FunnelTracker'
|
2026-06-18 09:26:26 +08:00
|
|
|
|
import type {
|
|
|
|
|
|
LogLevel,
|
|
|
|
|
|
Environment,
|
|
|
|
|
|
IssueEvent,
|
|
|
|
|
|
LogEvent,
|
|
|
|
|
|
BugCollectEvent,
|
|
|
|
|
|
BreadcrumbItem,
|
|
|
|
|
|
DeviceInfo,
|
|
|
|
|
|
Level,
|
|
|
|
|
|
} from './types'
|
2026-06-16 12:10:28 +08:00
|
|
|
|
|
|
|
|
|
|
// ─── Internal state ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-06-18 09:26:26 +08:00
|
|
|
|
const SDK_NAME = 'bugcollect.rn'
|
|
|
|
|
|
const SDK_VERSION = '0.2.0'
|
|
|
|
|
|
const MAX_BREADCRUMBS = 100
|
|
|
|
|
|
|
2026-06-16 12:10:28 +08:00
|
|
|
|
let _logLevel: LogLevel = 'warn'
|
|
|
|
|
|
let _environment: Environment = 'production'
|
|
|
|
|
|
let _queue: LogQueue | null = null
|
|
|
|
|
|
let _errorCaptureStarted = false
|
2026-06-25 17:50:29 +08:00
|
|
|
|
let _httpInterceptorStarted = false
|
2026-06-18 09:26:26 +08:00
|
|
|
|
let _breadcrumbs: BreadcrumbItem[] = []
|
2026-06-16 12:10:28 +08:00
|
|
|
|
|
2026-06-25 17:50:29 +08:00
|
|
|
|
// ─── 上报频率控制 / 采样(降低服务端存储与流量压力)───────────────────────────
|
|
|
|
|
|
// 同一指纹在滑动窗口内最多上报 N 次,避免相同错误刷屏;fatal 不受限、不抽样。
|
|
|
|
|
|
const RATE_WINDOW_MS = 60_000
|
|
|
|
|
|
const RATE_MAX_PER_FINGERPRINT = 5
|
|
|
|
|
|
let _sampleRate = 1 // 1=全量;0~1 对非致命事件抽样
|
|
|
|
|
|
const _fingerprintHits = new Map<string, number[]>()
|
|
|
|
|
|
|
|
|
|
|
|
function _isFatal(event: BugCollectEvent): boolean {
|
|
|
|
|
|
return event.type === 'issue' && event.level === 'fatal'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _eventKey(event: BugCollectEvent): string {
|
|
|
|
|
|
return event.type === 'issue' ? event.fingerprint : `event:${event.name}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 是否允许上报:fatal 必报;否则先采样、再按指纹滑动窗口限频。 */
|
|
|
|
|
|
function _shouldReport(event: BugCollectEvent): boolean {
|
|
|
|
|
|
if (_isFatal(event)) return true
|
|
|
|
|
|
if (_sampleRate < 1 && Math.random() > _sampleRate) return false
|
|
|
|
|
|
const key = _eventKey(event)
|
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
|
const hits = (_fingerprintHits.get(key) ?? []).filter(t => now - t < RATE_WINDOW_MS)
|
|
|
|
|
|
if (hits.length >= RATE_MAX_PER_FINGERPRINT) {
|
|
|
|
|
|
_fingerprintHits.set(key, hits)
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
hits.push(now)
|
|
|
|
|
|
_fingerprintHits.set(key, hits)
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 12:10:28 +08:00
|
|
|
|
const _sessionId: string = _generateSessionId()
|
|
|
|
|
|
|
|
|
|
|
|
function _generateSessionId(): string {
|
2026-06-18 09:26:26 +08:00
|
|
|
|
return 'xxxx-xxxx-xxxx'.replace(/x/g, () => ((Math.random() * 16) | 0).toString(16))
|
2026-06-16 12:10:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _levelNum(level: LogLevel): number {
|
|
|
|
|
|
return { debug: 0, info: 1, warn: 2, error: 3 }[level]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 09:26:26 +08:00
|
|
|
|
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'
|
|
|
|
|
|
}
|
2026-06-16 12:10:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _getAppVersion(): string {
|
|
|
|
|
|
const constants = Platform.constants as Record<string, unknown>
|
|
|
|
|
|
const v = constants['appVersion'] ?? constants['Version']
|
|
|
|
|
|
return typeof v === 'string' ? v : String(v ?? '0.0.0')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 09:26:26 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 17:39:18 +08:00
|
|
|
|
function _enqueue(event: BugCollectEvent): void {
|
2026-06-16 12:10:28 +08:00
|
|
|
|
if (!_queue) {
|
|
|
|
|
|
const cfg = getConfig()
|
2026-06-16 17:39:18 +08:00
|
|
|
|
if (!cfg.bugCollectApiUrl || !cfg.bugCollectEnabled) return
|
|
|
|
|
|
_queue = new LogQueue({ bugCollectApiUrl: cfg.bugCollectApiUrl, appKey: cfg.appKey })
|
2026-06-16 12:10:28 +08:00
|
|
|
|
}
|
2026-06-25 17:50:29 +08:00
|
|
|
|
if (!_shouldReport(event)) return
|
2026-06-16 12:10:28 +08:00
|
|
|
|
void _queue.push(event)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 09:26:26 +08:00
|
|
|
|
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 },
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 17:39:18 +08:00
|
|
|
|
// ─── BugCollect public API ────────────────────────────────────────────────────
|
2026-06-16 12:10:28 +08:00
|
|
|
|
|
2026-06-16 17:39:18 +08:00
|
|
|
|
export const BugCollect = {
|
2026-06-16 12:10:28 +08:00
|
|
|
|
setLogLevel(level: LogLevel): void {
|
|
|
|
|
|
_logLevel = level
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
setEnvironment(env: Environment): void {
|
|
|
|
|
|
_environment = env
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-25 17:50:29 +08:00
|
|
|
|
/** 设置非致命事件的采样率(0~1,默认 1=全量)。fatal 始终全量上报。 */
|
|
|
|
|
|
setSampleRate(rate: number): void {
|
|
|
|
|
|
_sampleRate = Math.max(0, Math.min(1, rate))
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-18 09:26:26 +08:00
|
|
|
|
/** 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). */
|
2026-06-16 12:10:28 +08:00
|
|
|
|
event(name: string, properties?: Record<string, unknown>): void {
|
|
|
|
|
|
if (!isInitialized()) return
|
|
|
|
|
|
const event: LogEvent = {
|
|
|
|
|
|
type: 'event',
|
|
|
|
|
|
name,
|
2026-06-17 18:02:10 +08:00
|
|
|
|
properties,
|
2026-06-16 12:10:28 +08:00
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
userId: getUserId() ?? undefined,
|
|
|
|
|
|
sessionId: _sessionId,
|
|
|
|
|
|
appKey: getConfig().appKey,
|
|
|
|
|
|
platform: _getPlatform(),
|
2026-06-17 18:02:10 +08:00
|
|
|
|
release: _getAppVersion(),
|
|
|
|
|
|
environment: _environment,
|
2026-06-18 09:26:26 +08:00
|
|
|
|
device: _getDeviceInfo(),
|
|
|
|
|
|
sdk: { name: SDK_NAME, version: SDK_VERSION },
|
2026-06-16 12:10:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
_enqueue(event)
|
|
|
|
|
|
FunnelTracker.track(name, properties)
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-18 09:26:26 +08:00
|
|
|
|
/** Upload a JS exception as an error-level issue. */
|
2026-06-16 12:10:28 +08:00
|
|
|
|
captureError(error: unknown, metadata?: Record<string, unknown>): void {
|
|
|
|
|
|
if (!isInitialized()) return
|
2026-06-18 09:26:26 +08:00
|
|
|
|
_enqueue(_buildIssue('error', error, metadata))
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/** 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))
|
2026-06-16 12:10:28 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/** Log a warning (if log level allows). */
|
|
|
|
|
|
warn(message: string, metadata?: Record<string, unknown>): void {
|
|
|
|
|
|
if (_levelNum(_logLevel) > _levelNum('warn')) return
|
2026-06-17 18:02:10 +08:00
|
|
|
|
if (!isInitialized()) return
|
|
|
|
|
|
const issue: IssueEvent = {
|
|
|
|
|
|
type: 'issue',
|
|
|
|
|
|
level: 'warning',
|
|
|
|
|
|
platform: _getPlatform(),
|
|
|
|
|
|
fingerprint: computeFingerprint('warning', message),
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
appKey: getConfig().appKey,
|
|
|
|
|
|
release: _getAppVersion(),
|
|
|
|
|
|
environment: _environment,
|
2026-06-18 09:26:26 +08:00
|
|
|
|
exception: { type: 'Warning', value: message },
|
|
|
|
|
|
breadcrumbs: _breadcrumbs.length > 0 ? [..._breadcrumbs] : undefined,
|
2026-06-17 18:02:10 +08:00
|
|
|
|
userId: getUserId() ?? undefined,
|
|
|
|
|
|
sessionId: _sessionId,
|
|
|
|
|
|
tags: metadata,
|
2026-06-18 09:26:26 +08:00
|
|
|
|
sdk: { name: SDK_NAME, version: SDK_VERSION },
|
2026-06-17 18:02:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
_enqueue(issue)
|
2026-06-16 12:10:28 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/** Log an informational event (if log level allows). */
|
|
|
|
|
|
info(message: string, metadata?: Record<string, unknown>): void {
|
|
|
|
|
|
if (_levelNum(_logLevel) > _levelNum('info')) return
|
2026-06-17 18:02:10 +08:00
|
|
|
|
if (!isInitialized()) return
|
|
|
|
|
|
const issue: IssueEvent = {
|
|
|
|
|
|
type: 'issue',
|
|
|
|
|
|
level: 'info',
|
|
|
|
|
|
platform: _getPlatform(),
|
|
|
|
|
|
fingerprint: computeFingerprint('info', message),
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
appKey: getConfig().appKey,
|
|
|
|
|
|
release: _getAppVersion(),
|
|
|
|
|
|
environment: _environment,
|
2026-06-18 09:26:26 +08:00
|
|
|
|
exception: { type: 'Info', value: message },
|
2026-06-17 18:02:10 +08:00
|
|
|
|
userId: getUserId() ?? undefined,
|
|
|
|
|
|
sessionId: _sessionId,
|
|
|
|
|
|
tags: metadata,
|
2026-06-18 09:26:26 +08:00
|
|
|
|
sdk: { name: SDK_NAME, version: SDK_VERSION },
|
2026-06-17 18:02:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
_enqueue(issue)
|
2026-06-16 12:10:28 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-25 17:50:29 +08:00
|
|
|
|
* Enable automatic capture of JS global errors and unhandled Promise rejections,
|
|
|
|
|
|
* plus HTTP/接口报错 自动上报(包裹 global.fetch)。
|
2026-06-16 12:10:28 +08:00
|
|
|
|
* Call once at app startup after XuqmSDK.initialize().
|
|
|
|
|
|
*/
|
|
|
|
|
|
startCapture(): void {
|
2026-06-25 17:50:29 +08:00
|
|
|
|
if (!_errorCaptureStarted) {
|
|
|
|
|
|
_errorCaptureStarted = true
|
|
|
|
|
|
ErrorCapture.start(BugCollect.captureError.bind(BugCollect))
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!_httpInterceptorStarted) {
|
|
|
|
|
|
_httpInterceptorStarted = true
|
|
|
|
|
|
HttpInterceptor.start(BugCollect.captureError.bind(BugCollect))
|
|
|
|
|
|
}
|
2026-06-16 12:10:28 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
defineFunnel: FunnelTracker.define.bind(FunnelTracker),
|
|
|
|
|
|
getFunnelProgress: FunnelTracker.getProgress.bind(FunnelTracker),
|
|
|
|
|
|
|
|
|
|
|
|
async flush(): Promise<void> {
|
|
|
|
|
|
if (_queue) await _queue.flush()
|
|
|
|
|
|
},
|
2026-06-16 13:10:16 +08:00
|
|
|
|
|
|
|
|
|
|
destroy(): void {
|
|
|
|
|
|
if (_queue) {
|
|
|
|
|
|
_queue.destroy()
|
|
|
|
|
|
_queue = null
|
|
|
|
|
|
}
|
2026-06-25 17:50:29 +08:00
|
|
|
|
if (_httpInterceptorStarted) {
|
|
|
|
|
|
HttpInterceptor.stop()
|
|
|
|
|
|
_httpInterceptorStarted = false
|
|
|
|
|
|
}
|
|
|
|
|
|
_fingerprintHits.clear()
|
2026-06-18 09:26:26 +08:00
|
|
|
|
_breadcrumbs = []
|
2026-06-16 13:10:16 +08:00
|
|
|
|
},
|
2026-06-16 12:10:28 +08:00
|
|
|
|
}
|