light-sentry-sdk/src/core/Reporter.ts

337 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_timestamptransaction 事件)
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));
}
}