368 行
12 KiB
TypeScript
368 行
12 KiB
TypeScript
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<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 _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<BugCollectEvent> {
|
||
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<void> {
|
||
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<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: 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<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 = {
|
||
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<string, unknown>): 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<string, unknown>): void {
|
||
if (!isInitialized()) return
|
||
_enqueueInBackground(_buildIssue('fatal', error, metadata))
|
||
},
|
||
|
||
/** Log a warning (if log level allows). */
|
||
warn(message: string, metadata?: Record<string, unknown>): 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<string, unknown>): 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<void> {
|
||
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<void> {
|
||
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<boolean> {
|
||
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<void> {
|
||
_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<void> {
|
||
stopCapture()
|
||
await _queue?.clear()
|
||
if (isInitialized()) await LogQueue.clearStored(getConfig().appKey)
|
||
_queue?.destroy()
|
||
_queue = null
|
||
},
|
||
}
|