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'
|
|
|
|
|
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-18 09:26:26 +08:00
|
|
|
let _breadcrumbs: BreadcrumbItem[] = []
|
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
|
|
|
}
|
|
|
|
|
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-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
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Enable automatic capture of JS global errors and unhandled Promise rejections.
|
|
|
|
|
* Call once at app startup after XuqmSDK.initialize().
|
|
|
|
|
*/
|
|
|
|
|
startCapture(): void {
|
|
|
|
|
if (_errorCaptureStarted) return
|
|
|
|
|
_errorCaptureStarted = true
|
2026-06-16 17:39:18 +08:00
|
|
|
ErrorCapture.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-18 09:26:26 +08:00
|
|
|
_breadcrumbs = []
|
2026-06-16 13:10:16 +08:00
|
|
|
},
|
2026-06-16 12:10:28 +08:00
|
|
|
}
|