import { Platform, Dimensions } from 'react-native' import AsyncStorage from '@react-native-async-storage/async-storage' import { getConfig, hasPrivacyConsent, isInitialized, getUserId } from '@xuqm/rn-common' import { _resolveBundleIdentity } from '@xuqm/rn-common/internal' 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 { BUGCOLLECT_SDK_NAME, BUGCOLLECT_SDK_VERSION } from './sdkInfo' import { ReportPolicy } from './reportPolicy' import { assertBugCollectPrivacyConsent, sanitizeDiagnostic } from './privacy' import type { LogLevel, Environment, IssueEvent, LogEvent, BugCollectEvent, BreadcrumbItem, DeviceInfo, Level, } from './types' // ─── Internal state ─────────────────────────────────────────────────────────── 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[] = [] let _platformDisabled = false const DISABLED_KEY_PREFIX = '@xuqm_bugcollect:disabled:' function disabledKey(): string { return `${DISABLED_KEY_PREFIX}${getConfig().appKey}` } const reportPolicy = new ReportPolicy() 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 { return reportPolicy.shouldReport(_eventKey(event), _isFatal(event)) } 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 _createQueue(): LogQueue { const cfg = getConfig() return new LogQueue({ bugCollectApiUrl: cfg.bugCollectApiUrl, appKey: cfg.appKey, onDisabled: () => { _platformDisabled = true void AsyncStorage.setItem(disabledKey(), 'true').catch(() => undefined) stopCapture() }, }) } async function _withBundleIdentity(event: BugCollectEvent): Promise { if (event.type !== 'issue') return event const stacktrace = event.exception?.stacktrace if (!stacktrace) return { ...event, symbolicationStatus: 'unavailable' } const identity = await _resolveBundleIdentity(stacktrace).catch(() => null) return identity ? { ...event, ...identity, symbolicationStatus: 'exact' } : { ...event, symbolicationStatus: 'unavailable' } } async function _enqueue(event: BugCollectEvent): Promise { if (!_canCollect()) return if (!_queue) _queue = _createQueue() if (!_shouldReport(event)) return const enriched = await _withBundleIdentity(event) await _queue?.push(sanitizeDiagnostic(enriched)) } function _enqueueInBackground(event: BugCollectEvent): void { void _enqueue(event).catch(() => undefined) } function _canCollect(): boolean { if (!isInitialized() || !hasPrivacyConsent() || _platformDisabled) return false const cfg = getConfig() return !cfg.debug && cfg.bugCollectEnabled && Boolean(cfg.bugCollectApiUrl) } function startCapture(): void { if (!_canCollect()) return if (!_errorCaptureStarted) { _errorCaptureStarted = true ErrorCapture.start(BugCollect.captureError.bind(BugCollect)) } if (!_httpInterceptorStarted) { _httpInterceptorStarted = true HttpInterceptor.start(BugCollect.captureError.bind(BugCollect)) } } function stopCapture(): void { if (_errorCaptureStarted) { ErrorCapture.stop() _errorCaptureStarted = false } if (_httpInterceptorStarted) { HttpInterceptor.stop() _httpInterceptorStarted = false } } 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: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION }, symbolicationStatus: 'unavailable', } } // ─── 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 { reportPolicy.setSampleRate(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: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION }, } _enqueueInBackground(event) FunnelTracker.track(name, properties) }, /** Upload a JS exception as an error-level issue. */ captureError(error: unknown, metadata?: Record): void { if (!isInitialized()) return _enqueueInBackground(_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 _enqueueInBackground(_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: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION }, symbolicationStatus: 'unavailable', } _enqueueInBackground(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: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION }, symbolicationStatus: 'unavailable', } _enqueueInBackground(issue) }, /** Debug 开发页的一次性测试入口;不改变真实隐私同意状态或自动采集开关。 */ async sendTestErrorAndFlush(message = 'Xuqm BugCollect test error'): Promise { if (!isInitialized()) throw new Error('[BugCollect] SDK is not initialized') assertBugCollectPrivacyConsent(hasPrivacyConsent()) const cfg = getConfig() if (!cfg.bugCollectEnabled || !cfg.bugCollectApiUrl || _platformDisabled) { throw new Error('[BugCollect] Service is disabled') } if (!_queue) { _queue = _createQueue() } const issue = await _withBundleIdentity( _buildIssue('error', new Error(message), { testEvent: true }), ) await _queue.push(sanitizeDiagnostic(issue)) await _queue.flush(true) }, defineFunnel: FunnelTracker.define.bind(FunnelTracker), getFunnelProgress: FunnelTracker.getProgress.bind(FunnelTracker), async flush(): Promise { if (_queue) await _queue.flush(true) }, destroy(): void { if (_queue) { _queue.destroy() _queue = null } stopCapture() reportPolicy.clear() _breadcrumbs = [] }, } /** 仅供包内初始化/隐私生命周期使用,不从 package index 导出。 */ export const _BugCollectLifecycle = { startCapture, async applyEnabledConfig(authoritative: boolean): Promise { if (authoritative) { _platformDisabled = false await AsyncStorage.removeItem(disabledKey()).catch(() => undefined) return true } _platformDisabled = (await AsyncStorage.getItem(disabledKey()).catch(() => null)) === 'true' return !_platformDisabled }, async disableAndClear(): Promise { _platformDisabled = true if (isInitialized()) { await AsyncStorage.setItem(disabledKey(), 'true').catch(() => undefined) } stopCapture() await _queue?.clear() if (isInitialized()) await LogQueue.clearStored(getConfig().appKey) _queue?.destroy() _queue = null }, async clearPendingAndStop(): Promise { stopCapture() await _queue?.clear() if (isInitialized()) await LogQueue.clearStored(getConfig().appKey) _queue?.destroy() _queue = null }, }