import type { DSNInfo, SentryEvent, ErrorEvent } from '../types'; import { getEnvelopeUrl } from '../utils/dsn'; import { now } from '../utils/helper'; import { encodeFields, FIELD_ENCODE_MAP } from '../utils/field-encoder'; export class Reporter { private dsn: DSNInfo; private maxRetries: number; private retryDelay: number; private useShortFields: boolean = true; // 是否使用短字段编码 constructor(dsn: DSNInfo, maxRetries: number, retryDelay: number) { this.dsn = dsn; this.maxRetries = maxRetries; this.retryDelay = retryDelay; } async report(events: SentryEvent[]): Promise { if (events.length === 0) return; const envelope = this.buildEnvelope(events); let retries = 0; while (retries <= this.maxRetries) { try { await this.send(envelope, false); return; } catch (e) { const status = (e as { status?: number }).status; if (status && status >= 400 && status < 500) { throw e; } retries++; if (retries > this.maxRetries) { throw e; } await this.delay(this.retryDelay * Math.pow(2, retries - 1)); } } } reportSync(events: SentryEvent[]): void { if (events.length === 0) return; const envelope = this.buildEnvelope(events); try { const success = this.sendSync(envelope); if (success) return; } catch { // ignore sync errors } this.sendViaImage(envelope); } /** * 构建 Sentry Envelope * * 优化: * 1. 批量元数据共享 - 同批次共享 release/environment/user * 2. 字段短编码 - 常用字段名使用短码 * 3. 时间戳相对化 - 批次内后续事件用差值 */ private buildEnvelope(events: SentryEvent[]): string { // 提取公共元数据(从第一个事件) const sharedMeta = this.extractSharedMeta(events); // 构建 header(包含公共元数据) const headerObj: Record = { event_id: this.generateEventId(), sent_at: new Date().toISOString(), meta: sharedMeta, }; // 如果启用短字段编码,在 header 中标记 if (this.useShortFields) { headerObj._sf = 1; // short fields flag } // 时间戳相对化:第一个事件带基准时间,后续事件用差值 const eventsWithRelativeTs = this.applyRelativeTimestamps(events, headerObj); const header = JSON.stringify(headerObj); const items: string[] = [header]; for (const event of eventsWithRelativeTs) { // 移除已共享的字段,减少重复 let strippedEvent = this.stripSharedFields(event, sharedMeta); // 短字段编码(如果启用) if (this.useShortFields) { strippedEvent = encodeFields(strippedEvent as Record) as SentryEvent; } const itemPayload = JSON.stringify(strippedEvent); const itemHeader = JSON.stringify({ type: this.getEnvelopeType(event), length: itemPayload.length, }); items.push(itemHeader, itemPayload); } return items.join('\n'); } /** * 时间戳相对化 * * 批次内: * - 第一个事件:完整时间戳 * - 后续事件:相对于第一个事件的毫秒差值(用 _dts 字段表示) * * 这样可以将时间戳从 13 位数字减少到 2-4 位数字 */ private applyRelativeTimestamps(events: SentryEvent[], headerObj: Record): SentryEvent[] { if (events.length <= 1) return events; const result: SentryEvent[] = []; const baseTimestamp = events[0].timestamp ? new Date(events[0].timestamp).getTime() : Date.now(); // 在 header 中标记使用相对时间戳 headerObj._rt = 1; // relative timestamp flag headerObj._bt = baseTimestamp; // base timestamp for (let i = 0; i < events.length; i++) { const event = { ...events[i] } as Record; if (i === 0) { // 第一个事件保持完整时间戳 result.push(event as SentryEvent); } else { // 后续事件用差值 const eventTs = event.timestamp ? new Date(event.timestamp as string | number).getTime() : Date.now(); const delta = eventTs - baseTimestamp; delete event.timestamp; event._dts = delta; // delta timestamp // 同时处理 start_timestamp(transaction 事件) if (event.start_timestamp) { const startTs = new Date(event.start_timestamp as string | number).getTime(); const startDelta = startTs - baseTimestamp; delete event.start_timestamp; event._dsts = startDelta; // delta start timestamp } result.push(event as SentryEvent); } } return result; } /** * 提取批次内公共元数据 */ private extractSharedMeta(events: SentryEvent[]): Record { if (events.length === 0) return {}; const firstEvent = events[0]; const meta: Record = {}; // 提取 release(如果所有事件都相同) if (firstEvent.release) { const allSameRelease = events.every(e => e.release === firstEvent.release); if (allSameRelease) { meta.release = firstEvent.release; } } // 提取 environment(如果所有事件都相同) if (firstEvent.environment) { const allSameEnv = events.every(e => e.environment === firstEvent.environment); if (allSameEnv) { meta.environment = firstEvent.environment; } } // 提取 user(如果所有事件都相同) if (firstEvent.user) { const allSameUser = events.every(e => JSON.stringify(e.user) === JSON.stringify(firstEvent.user) ); if (allSameUser) { meta.user = firstEvent.user; } } // 提取 env(编码后的环境信息) const firstEnv = (firstEvent as Record).env; if (firstEnv) { meta.env = firstEnv; } return meta; } /** * 移除已共享的字段,减少重复 */ private stripSharedFields(event: SentryEvent, meta: Record): SentryEvent { const stripped = { ...event }; // 移除已共享的字段 if (meta.release && stripped.release === meta.release) { delete stripped.release; } if (meta.environment && stripped.environment === meta.environment) { delete stripped.environment; } if (meta.user && JSON.stringify(stripped.user) === JSON.stringify(meta.user)) { delete stripped.user; } if (meta.env) { delete (stripped as Record).env; } return stripped; } private getEnvelopeType(event: SentryEvent): string { switch (event.type) { case 'error': return 'event'; case 'performance': return 'transaction'; default: return event.type; } } private generateEventId(): string { return 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'.replace(/[x]/g, () => { return ((Math.random() * 16) | 0).toString(16); }); } private async send(body: string, isSync: boolean = false): Promise { const url = getEnvelopeUrl(this.dsn); if (navigator.sendBeacon) { try { const blob = new Blob([body], { type: 'application/x-sentry-envelope' }); const success = navigator.sendBeacon(url, blob); if (success) return; } catch { // sendBeacon failed, fall through } } if (typeof fetch === 'function') { try { const response = await fetch(url, { method: 'POST', body, headers: { 'Content-Type': 'application/x-sentry-envelope', }, keepalive: true, }); if (response.ok) return; const err = new Error(`HTTP ${response.status}`) as Error & { status: number }; err.status = response.status; throw err; } catch (e) { throw e; } } if (typeof XMLHttpRequest !== 'undefined') { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', url, !isSync); xhr.setRequestHeader('Content-Type', 'application/x-sentry-envelope'); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { const err = new Error(`HTTP ${xhr.status}`) as Error & { status: number }; err.status = xhr.status; reject(err); } }; xhr.onerror = () => reject(new Error('Network error')); xhr.send(body); }); } throw new Error('No transport available'); } private sendSync(body: string): boolean { const url = getEnvelopeUrl(this.dsn); if (navigator.sendBeacon) { try { const blob = new Blob([body], { type: 'application/x-sentry-envelope' }); return navigator.sendBeacon(url, blob); } catch { return false; } } if (typeof XMLHttpRequest !== 'undefined') { try { const xhr = new XMLHttpRequest(); xhr.open('POST', url, false); xhr.setRequestHeader('Content-Type', 'application/x-sentry-envelope'); xhr.send(body); return xhr.status >= 200 && xhr.status < 300; } catch { return false; } } return false; } private sendViaImage(body: string): boolean { try { const url = getEnvelopeUrl(this.dsn); const img = new Image(); const encoded = encodeURIComponent(btoa(body)); img.src = url + '&sentry_data=' + encoded.substring(0, 2000); return true; } catch { return false; } } private delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } }