XuqmGroup-RNSDK/packages/bugcollect/src/BugCollect.ts

368 行
12 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

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
},
}