diff --git a/src/bugcollect/bugcollect.ts b/src/bugcollect/bugcollect.ts new file mode 100644 index 0000000..8b9a15f --- /dev/null +++ b/src/bugcollect/bugcollect.ts @@ -0,0 +1,244 @@ +/** + * BugCollect 崩溃收集 SDK + */ + +import type { BugCollectOptions, BugCollectAPI, IssueEvent, LogEvent, Breadcrumb, DeviceInfo, LogLevel } from './types' +import { computeFingerprint } from './fingerprint' +import { startCrashCapture, stopCrashCapture } from './crash-capture' +import { LogQueue } from './log-queue' +import { trackFunnelEvent } from './funnel-tracker' + +const SDK_NAME = 'bugcollect.vue3' +const SDK_VERSION = '1.0.0' +const MAX_BREADCRUMBS = 50 + +const LOG_LEVELS: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, + none: 4, +} + +let _options: BugCollectOptions | null = null +let _queue: LogQueue | null = null +let _breadcrumbs: Breadcrumb[] = [] +let _pendingErrors: IssueEvent[] = [] +let _initialized = false + +/** + * 初始化 BugCollect + */ +export function initBugCollect(options: BugCollectOptions): void { + if (_initialized) return + _options = { + release: '1.0.0', + environment: 'production', + logLevel: 'warn', + ...options, + } + _queue = new LogQueue(_options.appKey, _options.baseUrl) + _initialized = true + + // 启动崩溃捕获 + startCrashCapture((error, tags) => { + captureError(error, tags) + }) + + // 刷新待处理的错误 + flushPendingErrors() +} + +/** + * 捕获错误 + */ +export function captureError(error: Error, tags?: Record, fingerprint?: string): void { + const event = buildIssueEvent('error', error, tags, fingerprint) + + if (!_initialized) { + _pendingErrors.push(event) + return + } + + flushPendingErrors() + _queue?.push(event) +} + +/** + * 捕获致命错误 + */ +export function captureCrash(error: Error, tags?: Record): void { + if (!_initialized) return + _queue?.push(buildIssueEvent('fatal', error, tags)) +} + +/** + * 记录事件 + */ +export function event(name: string, properties?: Record): void { + if (!_initialized || !_options) return + + const logEvent: LogEvent = { + type: 'event', + eventId: generateUuid(), + name, + properties, + appKey: _options.appKey, + userId: _options.userId, + platform: 'web', + release: _options.release ?? '1.0.0', + environment: _options.environment ?? 'production', + device: buildDeviceInfo(), + sdk: { name: SDK_NAME, version: SDK_VERSION }, + } + + _queue?.push(logEvent) + trackFunnelEvent(name, properties) +} + +/** + * 添加面包屑 + */ +export function addBreadcrumb(category: string, message: string, level = 'info', data?: Record): void { + if (_breadcrumbs.length >= MAX_BREADCRUMBS) { + _breadcrumbs.shift() + } + _breadcrumbs.push({ + timestamp: Date.now(), + category, + message, + level, + data, + }) +} + +/** + * 记录警告 + */ +export function warn(message: string, tags?: Record): void { + if (!_initialized || !_options) return + if (LOG_LEVELS[_options.logLevel ?? 'warn'] > LOG_LEVELS.warn) return + + const issueEvent: IssueEvent = { + type: 'issue', + eventId: generateUuid(), + level: 'warning', + platform: 'web', + fingerprint: '', // will be computed + appKey: _options.appKey, + exception: { type: 'Warning', value: message }, + breadcrumbs: [..._breadcrumbs], + release: _options.release ?? '1.0.0', + environment: _options.environment ?? 'production', + userId: _options.userId, + device: buildDeviceInfo(), + tags, + sdk: { name: SDK_NAME, version: SDK_VERSION }, + } + + // Compute fingerprint async + computeFingerprint('Warning', message, '').then(fp => { + issueEvent.fingerprint = fp + _queue?.push(issueEvent) + }) +} + +/** + * 记录信息 + */ +export function info(message: string, tags?: Record): void { + if (!_initialized || !_options) return + if (LOG_LEVELS[_options.logLevel ?? 'warn'] > LOG_LEVELS.info) return + event('__log_info', { ...tags, message }) +} + +/** + * 刷新队列 + */ +export async function flush(): Promise { + await _queue?.flush() +} + +/** + * 销毁 + */ +export function destroy(): void { + stopCrashCapture() + _queue?.destroy() + _queue = null + _breadcrumbs = [] + _initialized = false +} + +/** + * 构建 IssueEvent + */ +function buildIssueEvent(level: IssueEvent['level'], error: Error, tags?: Record, fingerprint?: string): IssueEvent { + const stacktrace = error.stack ?? '' + + const event: IssueEvent = { + type: 'issue', + eventId: generateUuid(), + level, + platform: 'web', + fingerprint: fingerprint ?? '', // will be computed async + appKey: _options?.appKey ?? '', + exception: { + type: error.name ?? 'Error', + value: error.message ?? '', + stacktrace, + }, + breadcrumbs: [..._breadcrumbs], + release: _options?.release ?? '1.0.0', + environment: _options?.environment ?? 'production', + userId: _options?.userId, + device: buildDeviceInfo(), + tags, + sdk: { name: SDK_NAME, version: SDK_VERSION }, + } + + // Compute fingerprint async + if (!fingerprint) { + computeFingerprint(error.name ?? 'Error', error.message ?? '', stacktrace).then(fp => { + event.fingerprint = fp + }) + } + + return event +} + +/** + * 构建设备信息 + */ +function buildDeviceInfo(): DeviceInfo { + return { + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined, + locale: typeof navigator !== 'undefined' ? navigator.language : undefined, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + screenWidth: typeof screen !== 'undefined' ? screen.width : undefined, + screenHeight: typeof screen !== 'undefined' ? screen.height : undefined, + } +} + +/** + * 刷新待处理的错误 + */ +function flushPendingErrors(): void { + if (_pendingErrors.length === 0) return + const errors = [..._pendingErrors] + _pendingErrors = [] + for (const error of errors) { + _queue?.push(error) + } +} + +/** + * 生成 UUID + */ +function generateUuid(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} diff --git a/src/bugcollect/crash-capture.ts b/src/bugcollect/crash-capture.ts new file mode 100644 index 0000000..e55e894 --- /dev/null +++ b/src/bugcollect/crash-capture.ts @@ -0,0 +1,40 @@ +/** + * Web 端崩溃捕获器 + */ + +type ErrorHandler = (error: Error, tags?: Record) => void + +let _started = false +let _handler: ErrorHandler | null = null + +/** + * 启动崩溃捕获 + */ +export function startCrashCapture(handler: ErrorHandler): void { + if (_started) return + _started = true + _handler = handler + + // 捕获未处理的错误 + window.addEventListener('error', (event) => { + if (event.error) { + handler(event.error, { type: 'uncaught_error', filename: event.filename, lineno: event.lineno, colno: event.colno }) + } + }) + + // 捕获未处理的 Promise rejection + window.addEventListener('unhandledrejection', (event) => { + const error = event.reason instanceof Error + ? event.reason + : new Error(String(event.reason)) + handler(error, { type: 'unhandled_rejection' }) + }) +} + +/** + * 停止崩溃捕获 + */ +export function stopCrashCapture(): void { + _started = false + _handler = null +} diff --git a/src/bugcollect/fingerprint.ts b/src/bugcollect/fingerprint.ts new file mode 100644 index 0000000..73fd03e --- /dev/null +++ b/src/bugcollect/fingerprint.ts @@ -0,0 +1,38 @@ +/** + * 错误指纹计算 + */ + +/** + * 计算错误指纹 + */ +export async function computeFingerprint(type: string, message: string, stacktrace?: string): Promise { + const normalizedMessage = normalizeMessage(message) + const top3Frames = extractTop3Frames(stacktrace) + const raw = `${type}:${normalizedMessage}:${top3Frames}` + + const encoder = new TextEncoder() + const data = encoder.encode(raw) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') +} + +/** + * 归一化消息(数字替换为 N,长 hex 替换为 H) + */ +function normalizeMessage(message: string): string { + return message + .replace(/\d+/g, 'N') + .replace(/[0-9a-fA-F]{8,}/g, 'H') +} + +/** + * 提取堆栈顶部帧 + */ +function extractTop3Frames(stacktrace?: string): string { + if (!stacktrace) return '' + return stacktrace + .split('\n') + .slice(0, 3) + .join('|') +} diff --git a/src/bugcollect/funnel-tracker.ts b/src/bugcollect/funnel-tracker.ts new file mode 100644 index 0000000..ed13c81 --- /dev/null +++ b/src/bugcollect/funnel-tracker.ts @@ -0,0 +1,26 @@ +/** + * 漏斗追踪器 + */ + +const sessions = new Map() + +/** + * 追踪事件 + */ +export function trackFunnelEvent(name: string, properties?: Record): void { + // 简化实现,后续可扩展 +} + +/** + * 获取当前会话的事件序列 + */ +export function getSessionEvents(sessionId: string): string[] { + return sessions.get(sessionId) ?? [] +} + +/** + * 清除会话 + */ +export function clearSession(sessionId: string): void { + sessions.delete(sessionId) +} diff --git a/src/bugcollect/index.ts b/src/bugcollect/index.ts new file mode 100644 index 0000000..7713126 --- /dev/null +++ b/src/bugcollect/index.ts @@ -0,0 +1,6 @@ +/** + * BugCollect 模块导出 + */ + +export { initBugCollect, captureError, captureCrash, event, addBreadcrumb, warn, info, flush, destroy } from './bugcollect' +export type { BugCollectOptions, BugCollectAPI, IssueEvent, LogEvent, ExceptionInfo, UserInfo, DeviceInfo, SdkInfo, Breadcrumb, LogLevel } from './types' diff --git a/src/bugcollect/log-queue.ts b/src/bugcollect/log-queue.ts new file mode 100644 index 0000000..ab606b9 --- /dev/null +++ b/src/bugcollect/log-queue.ts @@ -0,0 +1,150 @@ +/** + * 日志队列 - 内存队列 + 定时上传 + */ + +import type { IssueEvent, LogEvent } from './types' + +const BATCH_SIZE = 30 +const FLUSH_INTERVAL_MS = 10000 +const MAX_QUEUE_SIZE = 500 +const MAX_RETRY = 3 +const STORAGE_KEY = '@xuqm_bugcollect:queue' + +type Event = IssueEvent | LogEvent + +export class LogQueue { + private memory: Event[] = [] + private flushTimer: ReturnType | null = null + private retryCount = 0 + private appKey: string + private baseUrl: string + + constructor(appKey: string, baseUrl: string) { + this.appKey = appKey + this.baseUrl = baseUrl + this.flushTimer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS) + } + + /** + * 推送事件 + */ + push(event: Event): void { + if (this.shouldPersist(event)) { + const stored = this.readStored() + if (stored.length >= MAX_QUEUE_SIZE) stored.shift() + stored.push(event) + this.writeStored(stored) + } else { + if (this.memory.length >= MAX_QUEUE_SIZE) this.memory.shift() + this.memory.push(event) + } + } + + /** + * 刷新队列 + */ + async flush(): Promise { + const stored = this.readStored() + const combined = [...stored, ...this.memory] + if (combined.length === 0) return + + const batch = combined.slice(0, BATCH_SIZE) + const storedUsed = Math.min(stored.length, BATCH_SIZE) + const memUsed = batch.length - storedUsed + + // 乐观移除 + this.writeStored(stored.slice(storedUsed)) + this.memory.splice(0, memUsed) + + const issues = batch.filter((e): e is IssueEvent => e.type === 'issue') + const events = batch.filter((e): e is LogEvent => e.type === 'event') + + try { + if (issues.length > 0) { + await this.post('/bugcollect/v1/issues/batch', issues) + } + if (events.length > 0) { + await this.post('/bugcollect/v1/events/batch', events) + } + this.retryCount = 0 + } catch { + this.retryCount++ + if (this.retryCount < MAX_RETRY) { + const toRequeue = batch.filter(e => this.shouldPersist(e)) + if (toRequeue.length > 0) { + const current = this.readStored() + this.writeStored([...toRequeue, ...current]) + } + } + } + } + + /** + * 销毁队列 + */ + destroy(): void { + if (this.flushTimer) { + clearInterval(this.flushTimer) + this.flushTimer = null + } + } + + /** + * 判断是否需要持久化 + */ + private shouldPersist(event: Event): boolean { + return event.type === 'issue' && (event.level === 'fatal' || event.level === 'error') + } + + /** + * 上传数据 + */ + private async post(path: string, data: Event[]): Promise { + const body = JSON.stringify({ + sentAt: new Date().toISOString(), + sdk: { name: 'bugcollect.vue3', version: '1.0.0' }, + events: data, + }) + + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-App-Key': this.appKey, + }, + body, + }) + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status}`) + } + } + + /** + * 读取持久化队列 + */ + private readStored(): Event[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + return JSON.parse(raw) as Event[] + } catch { + return [] + } + } + + /** + * 写入持久化队列 + */ + private writeStored(queue: Event[]): void { + try { + if (queue.length === 0) { + localStorage.removeItem(STORAGE_KEY) + } else { + localStorage.setItem(STORAGE_KEY, JSON.stringify(queue)) + } + } catch { + // ignore + } + } +} diff --git a/src/bugcollect/types.ts b/src/bugcollect/types.ts new file mode 100644 index 0000000..43785a0 --- /dev/null +++ b/src/bugcollect/types.ts @@ -0,0 +1,93 @@ +/** + * BugCollect 数据模型 + */ + +export interface IssueEvent { + type: 'issue' + eventId: string + level: 'fatal' | 'error' | 'warning' | 'info' | 'debug' + platform: string + fingerprint: string + appKey: string + exception?: ExceptionInfo + breadcrumbs?: Breadcrumb[] + release: string + environment: string + userId?: string + user?: UserInfo + device?: DeviceInfo + tags?: Record + sdk?: SdkInfo +} + +export interface LogEvent { + type: 'event' + eventId: string + name: string + properties?: Record + appKey: string + userId?: string + platform: string + release: string + environment: string + device?: DeviceInfo + sdk?: SdkInfo +} + +export interface ExceptionInfo { + type: string + value: string + stacktrace?: string +} + +export interface UserInfo { + id: string +} + +export interface DeviceInfo { + model?: string + manufacturer?: string + osName?: string + osVersion?: string + locale?: string + timezone?: string + userAgent?: string + screenWidth?: number + screenHeight?: number + buildType?: string +} + +export interface SdkInfo { + name: string + version: string +} + +export interface Breadcrumb { + timestamp: number + category: string + message: string + level: string + data?: Record +} + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none' + +export interface BugCollectOptions { + appKey: string + baseUrl: string + release?: string + environment?: string + logLevel?: LogLevel + userId?: string +} + +export interface BugCollectAPI { + captureError(error: Error, tags?: Record, fingerprint?: string): void + captureCrash(error: Error, tags?: Record): void + event(name: string, properties?: Record): void + addBreadcrumb(category: string, message: string, level?: string, data?: Record): void + warn(message: string, tags?: Record): void + info(message: string, tags?: Record): void + flush(): Promise + destroy(): void +} diff --git a/src/index.ts b/src/index.ts index 418839c..3ad2742 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export { init, login, logout, setToken, setUserId, getToken, getUserId, getConfig } from './core/sdk' export { http } from './core/http' export { ImClient } from './im/ImClient' +export { initBugCollect, captureError, captureCrash, event, addBreadcrumb, warn, info, flush, destroy } from './bugcollect' export { acceptFriendRequest, acceptGroupJoinRequest, @@ -90,3 +91,4 @@ export type { ImEventMap, ApiResponse, } from './types' +export type { BugCollectOptions, BugCollectAPI, IssueEvent, LogEvent, ExceptionInfo, UserInfo, DeviceInfo, SdkInfo, Breadcrumb, LogLevel } from './bugcollect'