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 { HttpInterceptor } from './interceptor/HttpInterceptor' import { computeFingerprint } from './fingerprint' import { FunnelTracker } from './funnel/FunnelTracker' 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 _httpInterceptorStarted = false let _breadcrumbs: BreadcrumbItem[] = [] // ─── 上报频率控制 / 采样(降低服务端存储与流量压力)─────────────────────────── // 同一指纹在滑动窗口内最多上报 N 次,避免相同错误刷屏;fatal 不受限、不抽样。 const RATE_WINDOW_MS = 60_000 const RATE_MAX_PER_FINGERPRINT = 5 let _sampleRate = 1 // 1=全量;0~1 对非致命事件抽样 const _fingerprintHits = new Map() 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 } const _sessionId: string = _generateSessionId() function _generateSessionId(): string { 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' | '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 { 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() if (!cfg.bugCollectApiUrl || !cfg.bugCollectEnabled) return _queue = new LogQueue({ bugCollectApiUrl: cfg.bugCollectApiUrl, appKey: cfg.appKey }) } if (!_shouldReport(event)) return 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 = { setLogLevel(level: LogLevel): void { _logLevel = level }, setEnvironment(env: Environment): void { _environment = env }, /** 设置非致命事件的采样率(0~1,默认 1=全量)。fatal 始终全量上报。 */ setSampleRate(rate: number): void { _sampleRate = Math.max(0, Math.min(1, rate)) }, /** 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 = { type: 'event', name, properties, timestamp: Date.now(), userId: getUserId() ?? undefined, sessionId: _sessionId, appKey: getConfig().appKey, platform: _getPlatform(), release: _getAppVersion(), environment: _environment, device: _getDeviceInfo(), sdk: { name: SDK_NAME, version: SDK_VERSION }, } _enqueue(event) FunnelTracker.track(name, properties) }, /** Upload a JS exception as an error-level issue. */ captureError(error: unknown, metadata?: Record): void { if (!isInitialized()) return _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). */ warn(message: string, metadata?: Record): void { if (_levelNum(_logLevel) > _levelNum('warn')) return 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, exception: { type: 'Warning', value: message }, breadcrumbs: _breadcrumbs.length > 0 ? [..._breadcrumbs] : undefined, userId: getUserId() ?? undefined, sessionId: _sessionId, tags: metadata, sdk: { name: SDK_NAME, version: SDK_VERSION }, } _enqueue(issue) }, /** Log an informational event (if log level allows). */ info(message: string, metadata?: Record): void { if (_levelNum(_logLevel) > _levelNum('info')) return 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, exception: { type: 'Info', value: message }, userId: getUserId() ?? undefined, sessionId: _sessionId, tags: metadata, sdk: { name: SDK_NAME, version: SDK_VERSION }, } _enqueue(issue) }, /** * Enable automatic capture of JS global errors and unhandled Promise rejections, * plus HTTP/接口报错 自动上报(包裹 global.fetch)。 * Call once at app startup after XuqmSDK.initialize(). */ startCapture(): void { if (!_errorCaptureStarted) { _errorCaptureStarted = true ErrorCapture.start(BugCollect.captureError.bind(BugCollect)) } if (!_httpInterceptorStarted) { _httpInterceptorStarted = true HttpInterceptor.start(BugCollect.captureError.bind(BugCollect)) } }, defineFunnel: FunnelTracker.define.bind(FunnelTracker), getFunnelProgress: FunnelTracker.getProgress.bind(FunnelTracker), async flush(): Promise { if (_queue) await _queue.flush() }, destroy(): void { if (_queue) { _queue.destroy() _queue = null } if (_httpInterceptorStarted) { HttpInterceptor.stop() _httpInterceptorStarted = false } _fingerprintHits.clear() _breadcrumbs = [] }, }