feat: 添加 Vue3 BugCollect 崩溃收集模块

新增模块:src/bugcollect/
- bugcollect.ts: 主入口,错误捕获 API
- crash-capture.ts: Web 端崩溃捕获(error + unhandledrejection)
- types.ts: 数据模型
- fingerprint.ts: 错误指纹计算(SHA-256)
- log-queue.ts: 内存队列 + 定时上传(localStorage 持久化)
- funnel-tracker.ts: 漏斗追踪

功能:
- captureError/captureCrash 捕获错误
- addBreadcrumb 添加面包屑
- event 记录自定义事件
- warn/info 日志级别记录
- 自动初始化(window error 事件捕获)

Co-Authored-By: Claude <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-19 12:32:54 +08:00
父节点 92bf6ff794
当前提交 f60fdcdd6f
共有 8 个文件被更改,包括 599 次插入0 次删除

244
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<LogLevel, number> = {
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<string, unknown>, 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<string, unknown>): void {
if (!_initialized) return
_queue?.push(buildIssueEvent('fatal', error, tags))
}
/**
*
*/
export function event(name: string, properties?: Record<string, unknown>): 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<string, unknown>): void {
if (_breadcrumbs.length >= MAX_BREADCRUMBS) {
_breadcrumbs.shift()
}
_breadcrumbs.push({
timestamp: Date.now(),
category,
message,
level,
data,
})
}
/**
*
*/
export function warn(message: string, tags?: Record<string, unknown>): 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<string, unknown>): 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<void> {
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<string, unknown>, 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)
})
}

查看文件

@ -0,0 +1,40 @@
/**
* Web
*/
type ErrorHandler = (error: Error, tags?: Record<string, unknown>) => 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
}

查看文件

@ -0,0 +1,38 @@
/**
*
*/
/**
*
*/
export async function computeFingerprint(type: string, message: string, stacktrace?: string): Promise<string> {
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('|')
}

查看文件

@ -0,0 +1,26 @@
/**
*
*/
const sessions = new Map<string, string[]>()
/**
*
*/
export function trackFunnelEvent(name: string, properties?: Record<string, unknown>): void {
// 简化实现,后续可扩展
}
/**
*
*/
export function getSessionEvents(sessionId: string): string[] {
return sessions.get(sessionId) ?? []
}
/**
*
*/
export function clearSession(sessionId: string): void {
sessions.delete(sessionId)
}

6
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'

150
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<typeof setInterval> | 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<void> {
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<void> {
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
}
}
}

93
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<string, unknown>
sdk?: SdkInfo
}
export interface LogEvent {
type: 'event'
eventId: string
name: string
properties?: Record<string, unknown>
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<string, unknown>
}
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<string, unknown>, fingerprint?: string): void
captureCrash(error: Error, tags?: Record<string, unknown>): void
event(name: string, properties?: Record<string, unknown>): void
addBreadcrumb(category: string, message: string, level?: string, data?: Record<string, unknown>): void
warn(message: string, tags?: Record<string, unknown>): void
info(message: string, tags?: Record<string, unknown>): void
flush(): Promise<void>
destroy(): void
}

查看文件

@ -1,6 +1,7 @@
export { init, login, logout, setToken, setUserId, getToken, getUserId, getConfig } from './core/sdk' export { init, login, logout, setToken, setUserId, getToken, getUserId, getConfig } from './core/sdk'
export { http } from './core/http' export { http } from './core/http'
export { ImClient } from './im/ImClient' export { ImClient } from './im/ImClient'
export { initBugCollect, captureError, captureCrash, event, addBreadcrumb, warn, info, flush, destroy } from './bugcollect'
export { export {
acceptFriendRequest, acceptFriendRequest,
acceptGroupJoinRequest, acceptGroupJoinRequest,
@ -90,3 +91,4 @@ export type {
ImEventMap, ImEventMap,
ApiResponse, ApiResponse,
} from './types' } from './types'
export type { BugCollectOptions, BugCollectAPI, IssueEvent, LogEvent, ExceptionInfo, UserInfo, DeviceInfo, SdkInfo, Breadcrumb, LogLevel } from './bugcollect'