337 lines
9.6 KiB
TypeScript
337 lines
9.6 KiB
TypeScript
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<void> {
|
||
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<string, unknown> = {
|
||
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<string, unknown>) 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<string, unknown>): 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<string, unknown>;
|
||
|
||
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<string, unknown> {
|
||
if (events.length === 0) return {};
|
||
|
||
const firstEvent = events[0];
|
||
const meta: Record<string, unknown> = {};
|
||
|
||
// 提取 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<string, unknown>).env;
|
||
if (firstEnv) {
|
||
meta.env = firstEnv;
|
||
}
|
||
|
||
return meta;
|
||
}
|
||
|
||
/**
|
||
* 移除已共享的字段,减少重复
|
||
*/
|
||
private stripSharedFields(event: SentryEvent, meta: Record<string, unknown>): 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<string, unknown>).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<void> {
|
||
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<void> {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
}
|
||
}
|