feat: light sentry sdk
This commit is contained in:
commit
c8ab7ca010
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import type { LightConfig, LightClient, DSNInfo, SentryEvent, EventLevel, UserInfo, Breadcrumb } from '../types';
|
||||||
|
declare class Client implements LightClient {
|
||||||
|
config: LightConfig;
|
||||||
|
dsn: DSNInfo;
|
||||||
|
private configManager;
|
||||||
|
private eventBus;
|
||||||
|
private queue;
|
||||||
|
private reporter;
|
||||||
|
private pluginManager;
|
||||||
|
private breadcrumbs;
|
||||||
|
private maxBreadcrumbs;
|
||||||
|
private enabled;
|
||||||
|
constructor(config: LightConfig);
|
||||||
|
init(): void;
|
||||||
|
private flushEvents;
|
||||||
|
private syncFlushEvents;
|
||||||
|
on(event: string, handler: (...args: unknown[]) => void): void;
|
||||||
|
off(event: string, handler: (...args: unknown[]) => void): void;
|
||||||
|
emit(event: string, ...args: unknown[]): void;
|
||||||
|
captureException(error: Error | unknown): void;
|
||||||
|
captureMessage(message: string, level?: EventLevel): void;
|
||||||
|
captureEvent(event: Partial<SentryEvent> & {
|
||||||
|
type: string;
|
||||||
|
}): void;
|
||||||
|
private buildErrorEvent;
|
||||||
|
private parseStackTrace;
|
||||||
|
setUser(user: UserInfo | null): void;
|
||||||
|
setTag(key: string, value: string): void;
|
||||||
|
setTags(tags: Record<string, string>): void;
|
||||||
|
setExtra(key: string, value: unknown): void;
|
||||||
|
addBreadcrumb(breadcrumb: Omit<Breadcrumb, 'timestamp'>): void;
|
||||||
|
flush(): Promise<void>;
|
||||||
|
disable(): void;
|
||||||
|
enable(): void;
|
||||||
|
use(plugin: unknown): void;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
export default Client;
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type { LightConfig, SentryEvent, UserInfo } from '../types';
|
||||||
|
export declare class ConfigManager {
|
||||||
|
private config;
|
||||||
|
private contexts;
|
||||||
|
private contextId;
|
||||||
|
private contextReported;
|
||||||
|
constructor(config: LightConfig);
|
||||||
|
markContextReported(): void;
|
||||||
|
get<K extends keyof LightConfig>(key: K): LightConfig[K];
|
||||||
|
getAll(): LightConfig;
|
||||||
|
set<K extends keyof LightConfig>(key: K, value: LightConfig[K]): void;
|
||||||
|
setUser(user: UserInfo | null): void;
|
||||||
|
setTags(tags: Record<string, string>): void;
|
||||||
|
setTag(key: string, value: string): void;
|
||||||
|
setExtra(key: string, value: unknown): void;
|
||||||
|
shouldSample(): boolean;
|
||||||
|
isIgnoredError(message: string): boolean;
|
||||||
|
/**
|
||||||
|
* 获取编码后的环境信息(延迟初始化,只在首次上报时计算)
|
||||||
|
* 注意:env.ts 的 getContexts 已经返回编码后的数据
|
||||||
|
*/
|
||||||
|
private getEncodedContext;
|
||||||
|
/**
|
||||||
|
* 将配置应用到事件
|
||||||
|
* @param event 事件对象
|
||||||
|
* @param includeFullContext 是否包含完整环境信息(批次内只有首个事件需要)
|
||||||
|
*/
|
||||||
|
applyToEvent(event: SentryEvent, includeFullContext?: boolean): SentryEvent;
|
||||||
|
/**
|
||||||
|
* 应用上下文分层策略:根据事件级别裁剪堆栈和 breadcrumbs
|
||||||
|
*/
|
||||||
|
private applyContextLevel;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
type Handler = (...args: unknown[]) => void;
|
||||||
|
export declare class EventBus {
|
||||||
|
private handlers;
|
||||||
|
on(event: string, handler: Handler): void;
|
||||||
|
off(event: string, handler: Handler): void;
|
||||||
|
emit(event: string, ...args: unknown[]): void;
|
||||||
|
once(event: string, handler: Handler): void;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
export {};
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { SentryEvent } from '../types';
|
||||||
|
import { EventBus } from './EventBus';
|
||||||
|
export declare class EventQueue {
|
||||||
|
private queue;
|
||||||
|
private maxSize;
|
||||||
|
private flushInterval;
|
||||||
|
private timer;
|
||||||
|
private eventBus;
|
||||||
|
private flushCallback;
|
||||||
|
private syncFlushCallback;
|
||||||
|
private lastFlushTime;
|
||||||
|
private dedupeMap;
|
||||||
|
private pendingCounts;
|
||||||
|
private errorRateWindow;
|
||||||
|
private paused;
|
||||||
|
private pauseTimer;
|
||||||
|
constructor(maxSize: number, flushInterval: number, eventBus: EventBus, flushCallback: (events: SentryEvent[]) => Promise<void>, syncFlushCallback: (events: SentryEvent[]) => void);
|
||||||
|
start(): void;
|
||||||
|
private startTimer;
|
||||||
|
private setupVisibilityListener;
|
||||||
|
private checkInfiniteLoop;
|
||||||
|
enqueue(event: SentryEvent): void;
|
||||||
|
private buildCountEvents;
|
||||||
|
private getDedupeKey;
|
||||||
|
flush(): Promise<void>;
|
||||||
|
flushSync(): void;
|
||||||
|
size(): number;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import type { LightPlugin, LightClient, SentryEvent } from '../types';
|
||||||
|
export declare class PluginManager {
|
||||||
|
private plugins;
|
||||||
|
private client;
|
||||||
|
constructor(client: LightClient);
|
||||||
|
add(plugin: LightPlugin): void;
|
||||||
|
remove(name: string): void;
|
||||||
|
get(name: string): LightPlugin | undefined;
|
||||||
|
has(name: string): boolean;
|
||||||
|
applyBeforeReport(event: SentryEvent): SentryEvent | null;
|
||||||
|
applyAfterReport(event: SentryEvent): void;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { DSNInfo, SentryEvent } from '../types';
|
||||||
|
export declare class Reporter {
|
||||||
|
private dsn;
|
||||||
|
private maxRetries;
|
||||||
|
private retryDelay;
|
||||||
|
private useShortFields;
|
||||||
|
constructor(dsn: DSNInfo, maxRetries: number, retryDelay: number);
|
||||||
|
report(events: SentryEvent[]): Promise<void>;
|
||||||
|
reportSync(events: SentryEvent[]): void;
|
||||||
|
/**
|
||||||
|
* 构建 Sentry Envelope
|
||||||
|
*
|
||||||
|
* 优化:
|
||||||
|
* 1. 批量元数据共享 - 同批次共享 release/environment/user
|
||||||
|
* 2. 字段短编码 - 常用字段名使用短码
|
||||||
|
* 3. 时间戳相对化 - 批次内后续事件用差值
|
||||||
|
*/
|
||||||
|
private buildEnvelope;
|
||||||
|
/**
|
||||||
|
* 时间戳相对化
|
||||||
|
*
|
||||||
|
* 批次内:
|
||||||
|
* - 第一个事件:完整时间戳
|
||||||
|
* - 后续事件:相对于第一个事件的毫秒差值(用 _dts 字段表示)
|
||||||
|
*
|
||||||
|
* 这样可以将时间戳从 13 位数字减少到 2-4 位数字
|
||||||
|
*/
|
||||||
|
private applyRelativeTimestamps;
|
||||||
|
/**
|
||||||
|
* 提取批次内公共元数据
|
||||||
|
*/
|
||||||
|
private extractSharedMeta;
|
||||||
|
/**
|
||||||
|
* 移除已共享的字段,减少重复
|
||||||
|
*/
|
||||||
|
private stripSharedFields;
|
||||||
|
private getEnvelopeType;
|
||||||
|
private generateEventId;
|
||||||
|
private send;
|
||||||
|
private sendSync;
|
||||||
|
private sendViaImage;
|
||||||
|
private delay;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,40 @@
|
||||||
|
import Client from './core/Client';
|
||||||
|
import ErrorPlugin from './plugins/ErrorPlugin';
|
||||||
|
import PerformancePlugin from './plugins/PerformancePlugin';
|
||||||
|
import NetworkPlugin from './plugins/NetworkPlugin';
|
||||||
|
import BehaviorPlugin from './plugins/BehaviorPlugin';
|
||||||
|
import OfflinePlugin from './plugins/OfflinePlugin';
|
||||||
|
import type { LightConfig, LightClient, SentryEvent, EventLevel, UserInfo, Breadcrumb, LightPlugin, ErrorEvent, PerformanceEvent, NetworkEvent, BehaviorEvent, DSNInfo } from './types';
|
||||||
|
declare function init(config: LightConfig): LightClient;
|
||||||
|
declare function getClient(): LightClient | null;
|
||||||
|
declare function captureException(error: Error | unknown): void;
|
||||||
|
declare function captureMessage(message: string, level?: EventLevel): void;
|
||||||
|
declare function captureEvent(event: Partial<SentryEvent> & {
|
||||||
|
type: string;
|
||||||
|
}): void;
|
||||||
|
declare function setUser(user: UserInfo | null): void;
|
||||||
|
declare function setTag(key: string, value: string): void;
|
||||||
|
declare function setTags(tags: Record<string, string>): void;
|
||||||
|
declare function setExtra(key: string, value: unknown): void;
|
||||||
|
declare function addBreadcrumb(breadcrumb: Omit<Breadcrumb, 'timestamp'>): void;
|
||||||
|
declare function flush(): Promise<void>;
|
||||||
|
declare function disable(): void;
|
||||||
|
declare function enable(): void;
|
||||||
|
export { init, getClient, captureException, captureMessage, captureEvent, setUser, setTag, setTags, setExtra, addBreadcrumb, flush, disable, enable, Client, ErrorPlugin, PerformancePlugin, NetworkPlugin, BehaviorPlugin, OfflinePlugin, };
|
||||||
|
export type { LightConfig, LightClient, LightPlugin, SentryEvent, ErrorEvent, PerformanceEvent, NetworkEvent, BehaviorEvent, EventLevel, UserInfo, Breadcrumb, DSNInfo, };
|
||||||
|
declare const _default: {
|
||||||
|
init: typeof init;
|
||||||
|
getClient: typeof getClient;
|
||||||
|
captureException: typeof captureException;
|
||||||
|
captureMessage: typeof captureMessage;
|
||||||
|
captureEvent: typeof captureEvent;
|
||||||
|
setUser: typeof setUser;
|
||||||
|
setTag: typeof setTag;
|
||||||
|
setTags: typeof setTags;
|
||||||
|
setExtra: typeof setExtra;
|
||||||
|
addBreadcrumb: typeof addBreadcrumb;
|
||||||
|
flush: typeof flush;
|
||||||
|
disable: typeof disable;
|
||||||
|
enable: typeof enable;
|
||||||
|
};
|
||||||
|
export default _default;
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { LightPlugin, LightClient } from '../types';
|
||||||
|
declare class BehaviorPlugin implements LightPlugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
private client;
|
||||||
|
private config;
|
||||||
|
private lastClickTime;
|
||||||
|
private lastScrollTime;
|
||||||
|
private maxScrollDepth;
|
||||||
|
private pageEnterTime;
|
||||||
|
private scrollReported;
|
||||||
|
setup(client: LightClient): void;
|
||||||
|
private loadConfig;
|
||||||
|
private shouldSample;
|
||||||
|
private getScrollDepth;
|
||||||
|
private trackPV;
|
||||||
|
private trackClick;
|
||||||
|
private trackRoute;
|
||||||
|
private trackPageDuration;
|
||||||
|
private trackScroll;
|
||||||
|
private reportScroll;
|
||||||
|
private reportBehavior;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
export default BehaviorPlugin;
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { LightPlugin, LightClient } from '../types';
|
||||||
|
declare class ErrorPlugin implements LightPlugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
private client;
|
||||||
|
private config;
|
||||||
|
setup(client: LightClient): void;
|
||||||
|
private loadConfig;
|
||||||
|
private shouldIgnoreError;
|
||||||
|
private shouldIgnoreScriptUrl;
|
||||||
|
private setupGlobalError;
|
||||||
|
private setupUnhandledRejection;
|
||||||
|
private setupResourceError;
|
||||||
|
private buildErrorEvent;
|
||||||
|
private parseStackTrace;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
export default ErrorPlugin;
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { LightPlugin, LightClient } from '../types';
|
||||||
|
declare class NetworkPlugin implements LightPlugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
private client;
|
||||||
|
private config;
|
||||||
|
private originalFetch;
|
||||||
|
private originalXHROpen;
|
||||||
|
private originalXHRSend;
|
||||||
|
setup(client: LightClient): void;
|
||||||
|
private loadConfig;
|
||||||
|
private shouldIgnore;
|
||||||
|
private shouldSample;
|
||||||
|
private patchFetch;
|
||||||
|
private patchXHR;
|
||||||
|
private reportNetwork;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
export default NetworkPlugin;
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type { LightPlugin, LightClient, SentryEvent } from '../types';
|
||||||
|
declare class OfflinePlugin implements LightPlugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
private client;
|
||||||
|
private config;
|
||||||
|
private db;
|
||||||
|
private isOnline;
|
||||||
|
private isSyncing;
|
||||||
|
private pendingEvents;
|
||||||
|
setup(client: LightClient): void;
|
||||||
|
private loadConfig;
|
||||||
|
private initDatabase;
|
||||||
|
private setupNetworkListeners;
|
||||||
|
private generateId;
|
||||||
|
private saveToIndexedDB;
|
||||||
|
private deleteOldestEvents;
|
||||||
|
private getAllStoredEvents;
|
||||||
|
private clearStoredEvents;
|
||||||
|
syncPendingEvents(): Promise<void>;
|
||||||
|
beforeReport(event: SentryEvent): SentryEvent | null;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
export default OfflinePlugin;
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type { LightPlugin, LightClient } from '../types';
|
||||||
|
declare class PerformancePlugin implements LightPlugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
private client;
|
||||||
|
private config;
|
||||||
|
setup(client: LightClient): void;
|
||||||
|
private loadConfig;
|
||||||
|
private shouldSample;
|
||||||
|
private observeWebVitals;
|
||||||
|
private observeFP;
|
||||||
|
private observeLCP;
|
||||||
|
private observeFID;
|
||||||
|
private observeCLS;
|
||||||
|
private observeFCP;
|
||||||
|
private observeTTFB;
|
||||||
|
private observeLongTasks;
|
||||||
|
private observeNavigation;
|
||||||
|
private observeResources;
|
||||||
|
private getRating;
|
||||||
|
private reportMetric;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
export default PerformancePlugin;
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
export type EventLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug';
|
||||||
|
export interface UserInfo {
|
||||||
|
id?: string;
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
export interface StackFrame {
|
||||||
|
filename: string;
|
||||||
|
function?: string;
|
||||||
|
lineno?: number;
|
||||||
|
colno?: number;
|
||||||
|
in_app?: boolean;
|
||||||
|
}
|
||||||
|
export interface ExceptionInfo {
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
stacktrace?: {
|
||||||
|
frames: StackFrame[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface RequestInfo {
|
||||||
|
url: string;
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
referrer?: string;
|
||||||
|
}
|
||||||
|
export interface BrowserContext {
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
export interface OSContext {
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
export interface DeviceContext {
|
||||||
|
family?: string;
|
||||||
|
model?: string;
|
||||||
|
brand?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
export interface Contexts {
|
||||||
|
browser?: BrowserContext;
|
||||||
|
os?: OSContext;
|
||||||
|
device?: DeviceContext;
|
||||||
|
}
|
||||||
|
export interface Breadcrumb {
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
category?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
level?: EventLevel;
|
||||||
|
}
|
||||||
|
export interface BaseEvent {
|
||||||
|
type: string;
|
||||||
|
level: EventLevel;
|
||||||
|
timestamp: number;
|
||||||
|
release?: string;
|
||||||
|
environment?: string;
|
||||||
|
user?: UserInfo;
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
breadcrumbs?: Breadcrumb[];
|
||||||
|
request?: RequestInfo;
|
||||||
|
contexts?: Contexts;
|
||||||
|
context_id?: string;
|
||||||
|
}
|
||||||
|
export interface ErrorEvent extends BaseEvent {
|
||||||
|
type: 'error';
|
||||||
|
message: string;
|
||||||
|
exception?: ExceptionInfo;
|
||||||
|
fingerprint?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
export interface PerformanceEvent extends BaseEvent {
|
||||||
|
type: 'performance';
|
||||||
|
metric: string;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
rating?: 'good' | 'needs-improvement' | 'poor';
|
||||||
|
}
|
||||||
|
export interface NetworkEvent extends BaseEvent {
|
||||||
|
type: 'network';
|
||||||
|
sub_type: 'fetch' | 'xhr';
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
status_code?: number;
|
||||||
|
duration?: number;
|
||||||
|
request_size?: number;
|
||||||
|
response_size?: number;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
export interface BehaviorEvent extends BaseEvent {
|
||||||
|
type: 'behavior';
|
||||||
|
sub_type: 'pv' | 'click' | 'route' | 'scroll' | 'duration';
|
||||||
|
page_url: string;
|
||||||
|
referrer?: string;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
export interface CountEvent extends BaseEvent {
|
||||||
|
type: 'count';
|
||||||
|
fingerprint: string;
|
||||||
|
count: number;
|
||||||
|
ts_start: number;
|
||||||
|
ts_end: number;
|
||||||
|
}
|
||||||
|
export type SentryEvent = ErrorEvent | PerformanceEvent | NetworkEvent | BehaviorEvent | CountEvent;
|
||||||
|
export interface DSNInfo {
|
||||||
|
protocol: string;
|
||||||
|
publicKey: string;
|
||||||
|
host: string;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
export interface LightConfig {
|
||||||
|
dsn: string;
|
||||||
|
release?: string;
|
||||||
|
environment?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
sampleRate?: number;
|
||||||
|
maxQueueSize?: number;
|
||||||
|
flushInterval?: number;
|
||||||
|
maxRetries?: number;
|
||||||
|
retryDelay?: number;
|
||||||
|
ignoreErrors?: (string | RegExp)[];
|
||||||
|
ignoreUrls?: (string | RegExp)[];
|
||||||
|
includePaths?: (string | RegExp)[];
|
||||||
|
beforeSend?: (event: SentryEvent) => SentryEvent | null;
|
||||||
|
user?: UserInfo;
|
||||||
|
plugins?: (LightPlugin | string)[];
|
||||||
|
[pluginName: string]: unknown;
|
||||||
|
}
|
||||||
|
export interface LightPlugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
setup(client: LightClient): void;
|
||||||
|
destroy?(): void;
|
||||||
|
beforeReport?(event: SentryEvent): SentryEvent | null;
|
||||||
|
afterReport?(event: SentryEvent): void;
|
||||||
|
}
|
||||||
|
export interface LightClient {
|
||||||
|
config: LightConfig;
|
||||||
|
dsn: DSNInfo;
|
||||||
|
on(event: string, handler: (...args: unknown[]) => void): void;
|
||||||
|
off(event: string, handler: (...args: unknown[]) => void): void;
|
||||||
|
emit(event: string, ...args: unknown[]): void;
|
||||||
|
captureException(error: Error | unknown): void;
|
||||||
|
captureMessage(message: string, level?: EventLevel): void;
|
||||||
|
captureEvent(event: Partial<SentryEvent> & {
|
||||||
|
type: string;
|
||||||
|
}): void;
|
||||||
|
setUser(user: UserInfo | null): void;
|
||||||
|
setTag(key: string, value: string): void;
|
||||||
|
setTags(tags: Record<string, string>): void;
|
||||||
|
setExtra(key: string, value: unknown): void;
|
||||||
|
addBreadcrumb(breadcrumb: Omit<Breadcrumb, 'timestamp'>): void;
|
||||||
|
flush(): Promise<void>;
|
||||||
|
disable(): void;
|
||||||
|
enable(): void;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import type { DSNInfo } from '../types';
|
||||||
|
export declare function parseDSN(dsn: string): DSNInfo;
|
||||||
|
export declare function getEnvelopeUrl(dsn: DSNInfo): string;
|
||||||
|
export declare function getStoreUrl(dsn: DSNInfo): string;
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
export interface BrowserInfo {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
major: string;
|
||||||
|
}
|
||||||
|
export interface OSInfo {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
export interface DeviceInfo {
|
||||||
|
type: 'desktop' | 'mobile' | 'tablet';
|
||||||
|
vendor: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
export interface EnvInfo {
|
||||||
|
browser: BrowserInfo;
|
||||||
|
os: OSInfo;
|
||||||
|
device: DeviceInfo;
|
||||||
|
url: string;
|
||||||
|
referrer: string;
|
||||||
|
title: string;
|
||||||
|
language: string;
|
||||||
|
user_agent: string;
|
||||||
|
screen_width: number;
|
||||||
|
screen_height: number;
|
||||||
|
viewport_width: number;
|
||||||
|
viewport_height: number;
|
||||||
|
}
|
||||||
|
export declare function getEnvInfo(): EnvInfo;
|
||||||
|
export declare function getPageUrl(): string;
|
||||||
|
export declare function getReferrer(): string;
|
||||||
|
export declare function sanitizeUrl(url: string): string;
|
||||||
|
export declare function shouldIgnoreUrl(url: string, ignoreUrls?: (string | RegExp)[]): boolean;
|
||||||
|
export declare function getContexts(): {
|
||||||
|
browser: {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
os: {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
device: {
|
||||||
|
type: "desktop" | "mobile" | "tablet";
|
||||||
|
brand: string;
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export declare function getContextId(): string;
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/**
|
||||||
|
* 字段短编码映射
|
||||||
|
*
|
||||||
|
* 将常用字段名编码为 2-3 个字符的短码,减少上报体积
|
||||||
|
*
|
||||||
|
* 编码前:{ "type": "error", "level": "error", "message": "..." }
|
||||||
|
* 编码后:{ "t": "e", "l": "e", "m": "..." }
|
||||||
|
*
|
||||||
|
* 注意:只对最外层高频字段编码,不对嵌套对象深度编码
|
||||||
|
* 避免复杂嵌套带来的解析问题和兼容性风险
|
||||||
|
*/
|
||||||
|
export declare const FIELD_ENCODE_MAP: Record<string, string>;
|
||||||
|
export declare const FIELD_DECODE_MAP: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* 编码事件字段名(SDK 端使用)
|
||||||
|
* 只对最外层字段编码,避免深度编码的复杂性
|
||||||
|
*/
|
||||||
|
export declare function encodeFields(obj: Record<string, unknown>): Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* 解码事件字段名(服务端使用)
|
||||||
|
* 只对最外层字段解码
|
||||||
|
*/
|
||||||
|
export declare function decodeFields(obj: Record<string, unknown>): Record<string, unknown>;
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export declare function hashString(str: string): number;
|
||||||
|
export declare function md5(str: string): string;
|
||||||
|
export declare function computeFingerprint(type: string, message: string, frames?: {
|
||||||
|
filename: string;
|
||||||
|
lineno?: number;
|
||||||
|
}[]): string;
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export declare function generateEventId(): string;
|
||||||
|
export declare function now(): number;
|
||||||
|
export declare function uuid(): string;
|
||||||
|
export declare function isFunction(fn: unknown): fn is (...args: unknown[]) => unknown;
|
||||||
|
export declare function isString(value: unknown): value is string;
|
||||||
|
export declare function isObject(value: unknown): value is Record<string, unknown>;
|
||||||
|
export declare function safeGet<T>(fn: () => T, defaultValue: T): T;
|
||||||
|
export declare function truncate(str: string, maxLen: number): string;
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"name": "light-sentry-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Lightweight Sentry-compatible browser SDK",
|
||||||
|
"main": "dist/index.cjs.js",
|
||||||
|
"module": "dist/index.esm.js",
|
||||||
|
"browser": "dist/index.iife.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "node scripts/build.js",
|
||||||
|
"dev": "node scripts/build.js --watch",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"sentry",
|
||||||
|
"monitoring",
|
||||||
|
"error-tracking",
|
||||||
|
"performance"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.20.0",
|
||||||
|
"jsdom": "^24.0.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"vitest": "^1.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
const esbuild = require('esbuild');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
const isWatch = process.argv.includes('--watch');
|
||||||
|
|
||||||
|
const commonOptions = {
|
||||||
|
entryPoints: [path.resolve(__dirname, '../src/index.ts')],
|
||||||
|
bundle: true,
|
||||||
|
minify: true,
|
||||||
|
sourcemap: false,
|
||||||
|
target: ['es2018'],
|
||||||
|
logLevel: 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成 TypeScript 声明文件
|
||||||
|
function generateDeclarations() {
|
||||||
|
console.log('Generating TypeScript declarations...');
|
||||||
|
|
||||||
|
const tmpDir = path.resolve(__dirname, '../dist-tmp');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 清理临时目录
|
||||||
|
if (fs.existsSync(tmpDir)) {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
|
||||||
|
// 生成所有 .d.ts 到临时目录(宽松模式,忽略错误)
|
||||||
|
execSync(
|
||||||
|
`tsc --skipLibCheck --declaration --declarationDir ${tmpDir} --outDir ${tmpDir} --strict false --noImplicitAny false --strictNullChecks false --noEmitOnError false --emitDeclarationOnly`,
|
||||||
|
{
|
||||||
|
cwd: path.resolve(__dirname, '..'),
|
||||||
|
stdio: 'pipe',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略 tsc 错误
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只复制 .d.ts 文件到 dist,删除多余的 .js
|
||||||
|
copyDtsFiles(tmpDir, path.resolve(__dirname, '../dist'));
|
||||||
|
|
||||||
|
// 清理临时目录
|
||||||
|
if (fs.existsSync(tmpDir)) {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Declarations generated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归复制 .d.ts 文件
|
||||||
|
function copyDtsFiles(srcDir, destDir) {
|
||||||
|
if (!fs.existsSync(destDir)) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(srcDir);
|
||||||
|
for (const file of files) {
|
||||||
|
const srcPath = path.join(srcDir, file);
|
||||||
|
const stat = fs.statSync(srcPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
copyDtsFiles(srcPath, path.join(destDir, file));
|
||||||
|
} else if (file.endsWith('.d.ts')) {
|
||||||
|
fs.copyFileSync(srcPath, path.join(destDir, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
const distDir = path.resolve(__dirname, '../dist');
|
||||||
|
|
||||||
|
// 清理 dist 目录
|
||||||
|
if (fs.existsSync(distDir)) {
|
||||||
|
fs.rmSync(distDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
fs.mkdirSync(distDir, { recursive: true });
|
||||||
|
|
||||||
|
const outputs = [
|
||||||
|
{
|
||||||
|
format: 'iife',
|
||||||
|
outfile: path.resolve(__dirname, '../dist/index.iife.js'),
|
||||||
|
globalName: 'LightSDK',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: 'esm',
|
||||||
|
outfile: path.resolve(__dirname, '../dist/index.esm.js'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: 'cjs',
|
||||||
|
outfile: path.resolve(__dirname, '../dist/index.cjs.js'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const output of outputs) {
|
||||||
|
await esbuild.build({
|
||||||
|
...commonOptions,
|
||||||
|
format: output.format,
|
||||||
|
outfile: output.outfile,
|
||||||
|
globalName: output.globalName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成声明文件
|
||||||
|
generateDeclarations();
|
||||||
|
|
||||||
|
console.log('Build complete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWatch) {
|
||||||
|
console.log('Watching for changes...');
|
||||||
|
const ctx = esbuild.context({
|
||||||
|
...commonOptions,
|
||||||
|
format: 'esm',
|
||||||
|
outfile: path.resolve(__dirname, '../dist/index.esm.js'),
|
||||||
|
});
|
||||||
|
ctx.watch();
|
||||||
|
} else {
|
||||||
|
build().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
import type { LightConfig, LightClient, DSNInfo, SentryEvent, EventLevel, UserInfo, Breadcrumb, ErrorEvent } from '../types';
|
||||||
|
import { parseDSN } from '../utils/dsn';
|
||||||
|
import { now } from '../utils/helper';
|
||||||
|
import { computeFingerprint } from '../utils/hash';
|
||||||
|
import { ConfigManager } from './ConfigManager';
|
||||||
|
import { EventBus } from './EventBus';
|
||||||
|
import { EventQueue } from './EventQueue';
|
||||||
|
import { Reporter } from './Reporter';
|
||||||
|
import { PluginManager } from './PluginManager';
|
||||||
|
|
||||||
|
class Client implements LightClient {
|
||||||
|
config: LightConfig;
|
||||||
|
dsn: DSNInfo;
|
||||||
|
|
||||||
|
private configManager: ConfigManager;
|
||||||
|
private eventBus: EventBus;
|
||||||
|
private queue: EventQueue;
|
||||||
|
private reporter: Reporter;
|
||||||
|
private pluginManager: PluginManager;
|
||||||
|
private breadcrumbs: Breadcrumb[] = [];
|
||||||
|
private maxBreadcrumbs = 20;
|
||||||
|
private enabled: boolean = true;
|
||||||
|
|
||||||
|
constructor(config: LightConfig) {
|
||||||
|
if (!config.dsn) {
|
||||||
|
throw new Error('DSN is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dsn = parseDSN(config.dsn);
|
||||||
|
this.configManager = new ConfigManager(config);
|
||||||
|
this.config = this.configManager.getAll();
|
||||||
|
this.eventBus = new EventBus();
|
||||||
|
this.reporter = new Reporter(
|
||||||
|
this.dsn,
|
||||||
|
config.maxRetries ?? 3,
|
||||||
|
config.retryDelay ?? 1000
|
||||||
|
);
|
||||||
|
this.pluginManager = new PluginManager(this);
|
||||||
|
this.queue = new EventQueue(
|
||||||
|
config.maxQueueSize ?? 100,
|
||||||
|
config.flushInterval ?? 5000,
|
||||||
|
this.eventBus,
|
||||||
|
async (events) => this.flushEvents(events),
|
||||||
|
(events) => this.syncFlushEvents(events)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.enabled = config.enabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
this.queue.start();
|
||||||
|
this.emit('ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushEvents(events: SentryEvent[]): Promise<void> {
|
||||||
|
const processedEvents: SentryEvent[] = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const processed = this.pluginManager.applyBeforeReport(event);
|
||||||
|
if (processed) {
|
||||||
|
processedEvents.push(processed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedEvents.length === 0) return;
|
||||||
|
|
||||||
|
// 批次内优化:只有第一个事件带完整环境信息
|
||||||
|
// 后续事件只带 context_id,服务端根据 ID 关联
|
||||||
|
let isFirstInBatch = true;
|
||||||
|
const finalEvents: SentryEvent[] = [];
|
||||||
|
|
||||||
|
for (const event of processedEvents) {
|
||||||
|
// 标记是否为批次首个事件
|
||||||
|
const withConfig = this.configManager.applyToEvent(event, isFirstInBatch);
|
||||||
|
isFirstInBatch = false; // 后续事件不再带完整 contexts
|
||||||
|
|
||||||
|
const finalEvent = this.config.get('beforeSend')
|
||||||
|
? this.config.get('beforeSend')!(withConfig)
|
||||||
|
: withConfig;
|
||||||
|
if (finalEvent) {
|
||||||
|
finalEvents.push(finalEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalEvents.length === 0) return;
|
||||||
|
|
||||||
|
await this.reporter.report(finalEvents);
|
||||||
|
// 批次结束后标记,后续批次不再带完整 contexts
|
||||||
|
this.configManager.markContextReported();
|
||||||
|
|
||||||
|
for (const event of finalEvents) {
|
||||||
|
this.pluginManager.applyAfterReport(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncFlushEvents(events: SentryEvent[]): void {
|
||||||
|
const processedEvents: SentryEvent[] = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const processed = this.pluginManager.applyBeforeReport(event);
|
||||||
|
if (processed) {
|
||||||
|
processedEvents.push(processed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedEvents.length === 0) return;
|
||||||
|
|
||||||
|
// 批次内优化:只有第一个事件带完整环境信息
|
||||||
|
let isFirstInBatch = true;
|
||||||
|
const finalEvents: SentryEvent[] = [];
|
||||||
|
|
||||||
|
for (const event of processedEvents) {
|
||||||
|
const withConfig = this.configManager.applyToEvent(event, isFirstInBatch);
|
||||||
|
isFirstInBatch = false;
|
||||||
|
|
||||||
|
const finalEvent = this.config.get('beforeSend')
|
||||||
|
? this.config.get('beforeSend')!(withConfig)
|
||||||
|
: withConfig;
|
||||||
|
if (finalEvent) {
|
||||||
|
finalEvents.push(finalEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalEvents.length === 0) return;
|
||||||
|
|
||||||
|
this.reporter.reportSync(finalEvents);
|
||||||
|
this.configManager.markContextReported();
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: string, handler: (...args: unknown[]) => void): void {
|
||||||
|
this.eventBus.on(event, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: string, handler: (...args: unknown[]) => void): void {
|
||||||
|
this.eventBus.off(event, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, ...args: unknown[]): void {
|
||||||
|
this.eventBus.emit(event, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
captureException(error: Error | unknown): void {
|
||||||
|
if (!this.enabled || !this.configManager.shouldSample()) return;
|
||||||
|
|
||||||
|
const errorEvent = this.buildErrorEvent(error);
|
||||||
|
if (this.configManager.isIgnoredError(errorEvent.message)) return;
|
||||||
|
|
||||||
|
errorEvent.breadcrumbs = [...this.breadcrumbs];
|
||||||
|
this.queue.enqueue(errorEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
captureMessage(message: string, level: EventLevel = 'info'): void {
|
||||||
|
if (!this.enabled || !this.configManager.shouldSample()) return;
|
||||||
|
|
||||||
|
const event: ErrorEvent = {
|
||||||
|
type: 'error',
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
timestamp: now(),
|
||||||
|
breadcrumbs: [...this.breadcrumbs],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.configManager.isIgnoredError(message)) return;
|
||||||
|
|
||||||
|
this.queue.enqueue(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
captureEvent(event: Partial<SentryEvent> & { type: string }): void {
|
||||||
|
if (!this.enabled || !this.configManager.shouldSample()) return;
|
||||||
|
|
||||||
|
const fullEvent = {
|
||||||
|
timestamp: now(),
|
||||||
|
level: 'info' as EventLevel,
|
||||||
|
...event,
|
||||||
|
} as SentryEvent;
|
||||||
|
|
||||||
|
this.queue.enqueue(fullEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildErrorEvent(error: Error | unknown): ErrorEvent {
|
||||||
|
const timestamp = now();
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const frames = this.parseStackTrace(error.stack);
|
||||||
|
const fingerprint = computeFingerprint(error.name, error.message, frames);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'error',
|
||||||
|
level: 'error',
|
||||||
|
message: error.message,
|
||||||
|
timestamp,
|
||||||
|
exception: {
|
||||||
|
type: error.name,
|
||||||
|
value: error.message,
|
||||||
|
stacktrace: {
|
||||||
|
frames: frames.slice(0, 5),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fingerprint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = String(error);
|
||||||
|
return {
|
||||||
|
type: 'error',
|
||||||
|
level: 'error',
|
||||||
|
message,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseStackTrace(stack?: string): { filename: string; function?: string; lineno?: number; colno?: number; in_app?: boolean }[] {
|
||||||
|
if (!stack) return [];
|
||||||
|
|
||||||
|
const frames: { filename: string; function?: string; lineno?: number; colno?: number; in_app?: boolean }[] = [];
|
||||||
|
const lines = stack.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/^\s*at\s*(.+?)\s*\((.+?):(\d+):(\d+)\)\s*$/);
|
||||||
|
if (match) {
|
||||||
|
const [, fn, filename, lineno, colno] = match;
|
||||||
|
frames.push({
|
||||||
|
filename,
|
||||||
|
function: fn,
|
||||||
|
lineno: parseInt(lineno, 10),
|
||||||
|
colno: parseInt(colno, 10),
|
||||||
|
in_app: !filename.includes('node_modules'),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const urlMatch = line.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/);
|
||||||
|
if (urlMatch) {
|
||||||
|
const [, filename, lineno, colno] = urlMatch;
|
||||||
|
frames.push({
|
||||||
|
filename,
|
||||||
|
lineno: parseInt(lineno, 10),
|
||||||
|
colno: parseInt(colno, 10),
|
||||||
|
in_app: !filename.includes('node_modules'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frames.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(user: UserInfo | null): void {
|
||||||
|
this.configManager.setUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTag(key: string, value: string): void {
|
||||||
|
this.configManager.setTag(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTags(tags: Record<string, string>): void {
|
||||||
|
this.configManager.setTags(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
setExtra(key: string, value: unknown): void {
|
||||||
|
this.configManager.setExtra(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
addBreadcrumb(breadcrumb: Omit<Breadcrumb, 'timestamp'>): void {
|
||||||
|
const fullBreadcrumb: Breadcrumb = {
|
||||||
|
...breadcrumb,
|
||||||
|
timestamp: now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.breadcrumbs.push(fullBreadcrumb);
|
||||||
|
if (this.breadcrumbs.length > this.maxBreadcrumbs) {
|
||||||
|
this.breadcrumbs.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async flush(): Promise<void> {
|
||||||
|
await this.queue.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
disable(): void {
|
||||||
|
this.enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
enable(): void {
|
||||||
|
this.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
use(plugin: unknown): void {
|
||||||
|
if (plugin && typeof plugin === 'object' && 'name' in plugin && 'setup' in plugin) {
|
||||||
|
this.pluginManager.add(plugin as never);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.queue.destroy();
|
||||||
|
this.pluginManager.destroy();
|
||||||
|
this.eventBus.destroy();
|
||||||
|
this.breadcrumbs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Client;
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
import type { LightConfig, SentryEvent, UserInfo } from '../types';
|
||||||
|
import { getContexts, getContextId, getPageUrl, getReferrer } from '../utils/env';
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: Partial<LightConfig> = {
|
||||||
|
enabled: true,
|
||||||
|
sampleRate: 1,
|
||||||
|
maxQueueSize: 100,
|
||||||
|
flushInterval: 5000,
|
||||||
|
maxRetries: 3,
|
||||||
|
retryDelay: 1000,
|
||||||
|
environment: 'production',
|
||||||
|
// 上下文分层策略:按事件级别决定堆栈深度和 breadcrumbs 数量
|
||||||
|
contextLevel: {
|
||||||
|
fatal: { maxStackFrames: 5, maxBreadcrumbs: 20 },
|
||||||
|
error: { maxStackFrames: 5, maxBreadcrumbs: 10 },
|
||||||
|
warning: { maxStackFrames: 3, maxBreadcrumbs: 5 },
|
||||||
|
info: { maxStackFrames: 0, maxBreadcrumbs: 0 },
|
||||||
|
debug: { maxStackFrames: 0, maxBreadcrumbs: 0 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ConfigManager {
|
||||||
|
private config: LightConfig;
|
||||||
|
private contexts: ReturnType<typeof getContexts> | null = null;
|
||||||
|
private contextId: string | null = null;
|
||||||
|
private contextReported: boolean = false;
|
||||||
|
|
||||||
|
constructor(config: LightConfig) {
|
||||||
|
this.config = { ...DEFAULT_CONFIG, ...config } as LightConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
markContextReported(): void {
|
||||||
|
this.contextReported = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get<K extends keyof LightConfig>(key: K): LightConfig[K] {
|
||||||
|
return this.config[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): LightConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
|
||||||
|
set<K extends keyof LightConfig>(key: K, value: LightConfig[K]): void {
|
||||||
|
this.config[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(user: UserInfo | null): void {
|
||||||
|
this.config.user = user || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTags(tags: Record<string, string>): void {
|
||||||
|
this.config.tags = { ...(this.config.tags || {}), ...tags };
|
||||||
|
}
|
||||||
|
|
||||||
|
setTag(key: string, value: string): void {
|
||||||
|
if (!this.config.tags) {
|
||||||
|
this.config.tags = {};
|
||||||
|
}
|
||||||
|
this.config.tags[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExtra(key: string, value: unknown): void {
|
||||||
|
if (!this.config.extra) {
|
||||||
|
this.config.extra = {};
|
||||||
|
}
|
||||||
|
(this.config.extra as Record<string, unknown>)[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldSample(): boolean {
|
||||||
|
const rate = this.config.sampleRate ?? 1;
|
||||||
|
if (rate >= 1) return true;
|
||||||
|
if (rate <= 0) return false;
|
||||||
|
return Math.random() < rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
isIgnoredError(message: string): boolean {
|
||||||
|
const patterns = this.config.ignoreErrors || [];
|
||||||
|
return patterns.some(pattern => {
|
||||||
|
if (typeof pattern === 'string') {
|
||||||
|
return message.includes(pattern);
|
||||||
|
}
|
||||||
|
return pattern.test(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取编码后的环境信息(延迟初始化,只在首次上报时计算)
|
||||||
|
* 注意:env.ts 的 getContexts 已经返回编码后的数据
|
||||||
|
*/
|
||||||
|
private getEncodedContext(): ReturnType<typeof getContexts> {
|
||||||
|
if (!this.contexts) {
|
||||||
|
this.contexts = getContexts();
|
||||||
|
}
|
||||||
|
return this.contexts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将配置应用到事件
|
||||||
|
* @param event 事件对象
|
||||||
|
* @param includeFullContext 是否包含完整环境信息(批次内只有首个事件需要)
|
||||||
|
*/
|
||||||
|
applyToEvent(event: SentryEvent, includeFullContext: boolean = true): SentryEvent {
|
||||||
|
if (this.config.release) {
|
||||||
|
event.release = this.config.release;
|
||||||
|
}
|
||||||
|
if (this.config.environment) {
|
||||||
|
event.environment = this.config.environment;
|
||||||
|
}
|
||||||
|
if (this.config.user) {
|
||||||
|
event.user = this.config.user;
|
||||||
|
}
|
||||||
|
if (this.config.tags) {
|
||||||
|
event.tags = { ...this.config.tags, ...(event.tags || {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.request) {
|
||||||
|
event.request = {
|
||||||
|
url: getPageUrl(),
|
||||||
|
referrer: getReferrer(),
|
||||||
|
};
|
||||||
|
} else if (!event.request.url) {
|
||||||
|
event.request.url = getPageUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
// context_id 总是带上,用于服务端关联环境信息
|
||||||
|
if (!this.contextId) {
|
||||||
|
this.contextId = getContextId();
|
||||||
|
}
|
||||||
|
event.context_id = this.contextId;
|
||||||
|
|
||||||
|
// 应用上下文分层策略:按事件级别裁剪堆栈和 breadcrumbs
|
||||||
|
this.applyContextLevel(event);
|
||||||
|
|
||||||
|
// 只有需要完整环境信息时才附加 contexts
|
||||||
|
// 批次内:首个事件带完整 contexts,后续事件只带 context_id
|
||||||
|
// 跨批次:首次上报带完整 contexts,后续批次只带 context_id
|
||||||
|
if (includeFullContext && !this.contextReported) {
|
||||||
|
if (!this.contexts) {
|
||||||
|
this.contexts = getContexts();
|
||||||
|
}
|
||||||
|
// 完整环境信息(已编码:browser.name -> c, os.name -> m 等)
|
||||||
|
event.contexts = this.contexts;
|
||||||
|
// 额外附加 env 字段,方便服务端识别编码格式
|
||||||
|
// env 字段使用短字段名:b=浏览器, bv=浏览器版本, os=系统, osv=系统版本
|
||||||
|
(event as Record<string, unknown>).env = {
|
||||||
|
b: this.contexts.browser?.name,
|
||||||
|
bv: this.contexts.browser?.version,
|
||||||
|
os: this.contexts.os?.name,
|
||||||
|
osv: this.contexts.os?.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用上下文分层策略:根据事件级别裁剪堆栈和 breadcrumbs
|
||||||
|
*/
|
||||||
|
private applyContextLevel(event: SentryEvent): void {
|
||||||
|
const contextLevel = this.config.contextLevel as Record<string, { maxStackFrames: number; maxBreadcrumbs: number }>;
|
||||||
|
if (!contextLevel) return;
|
||||||
|
|
||||||
|
const level = event.level || 'error';
|
||||||
|
const levelConfig = contextLevel[level] || contextLevel['error'];
|
||||||
|
|
||||||
|
// 裁剪堆栈帧
|
||||||
|
if (event.exception?.stacktrace?.frames && levelConfig.maxStackFrames > 0) {
|
||||||
|
event.exception.stacktrace.frames = event.exception.stacktrace.frames.slice(0, levelConfig.maxStackFrames);
|
||||||
|
} else if (levelConfig.maxStackFrames === 0 && event.exception?.stacktrace?.frames) {
|
||||||
|
// info/debug 级别不需要堆栈
|
||||||
|
delete event.exception.stacktrace;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 裁剪 breadcrumbs
|
||||||
|
if (event.breadcrumbs && levelConfig.maxBreadcrumbs > 0) {
|
||||||
|
event.breadcrumbs = event.breadcrumbs.slice(-levelConfig.maxBreadcrumbs);
|
||||||
|
} else if (levelConfig.maxBreadcrumbs === 0) {
|
||||||
|
// info/debug 级别不需要 breadcrumbs
|
||||||
|
delete event.breadcrumbs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
type Handler = (...args: unknown[]) => void;
|
||||||
|
|
||||||
|
export class EventBus {
|
||||||
|
private handlers: Map<string, Handler[]> = new Map();
|
||||||
|
|
||||||
|
on(event: string, handler: Handler): void {
|
||||||
|
if (!this.handlers.has(event)) {
|
||||||
|
this.handlers.set(event, []);
|
||||||
|
}
|
||||||
|
this.handlers.get(event)!.push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: string, handler: Handler): void {
|
||||||
|
const eventHandlers = this.handlers.get(event);
|
||||||
|
if (eventHandlers) {
|
||||||
|
const index = eventHandlers.indexOf(handler);
|
||||||
|
if (index > -1) {
|
||||||
|
eventHandlers.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, ...args: unknown[]): void {
|
||||||
|
const eventHandlers = this.handlers.get(event);
|
||||||
|
if (eventHandlers) {
|
||||||
|
for (const handler of eventHandlers) {
|
||||||
|
try {
|
||||||
|
handler(...args);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[LightSDK] EventBus handler error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
once(event: string, handler: Handler): void {
|
||||||
|
const wrapped = (...args: unknown[]) => {
|
||||||
|
this.off(event, wrapped);
|
||||||
|
handler(...args);
|
||||||
|
};
|
||||||
|
this.on(event, wrapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.handlers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,247 @@
|
||||||
|
import type { SentryEvent, ErrorEvent, CountEvent } from '../types';
|
||||||
|
import { EventBus } from './EventBus';
|
||||||
|
import { now } from '../utils/helper';
|
||||||
|
|
||||||
|
interface DedupeEntry {
|
||||||
|
count: number;
|
||||||
|
lastTime: number;
|
||||||
|
firstReported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventQueue {
|
||||||
|
private queue: SentryEvent[] = [];
|
||||||
|
private maxSize: number;
|
||||||
|
private flushInterval: number;
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private eventBus: EventBus;
|
||||||
|
private flushCallback: (events: SentryEvent[]) => Promise<void>;
|
||||||
|
private syncFlushCallback: (events: SentryEvent[]) => void;
|
||||||
|
private lastFlushTime: number = 0;
|
||||||
|
private dedupeMap: Map<string, DedupeEntry> = new Map();
|
||||||
|
private pendingCounts: Map<string, { count: number; ts_start: number; ts_end: number }> = new Map();
|
||||||
|
|
||||||
|
private errorRateWindow: { [key: string]: number[] } = {};
|
||||||
|
private paused: boolean = false;
|
||||||
|
private pauseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
maxSize: number,
|
||||||
|
flushInterval: number,
|
||||||
|
eventBus: EventBus,
|
||||||
|
flushCallback: (events: SentryEvent[]) => Promise<void>,
|
||||||
|
syncFlushCallback: (events: SentryEvent[]) => void
|
||||||
|
) {
|
||||||
|
this.maxSize = maxSize;
|
||||||
|
this.flushInterval = flushInterval;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
this.flushCallback = flushCallback;
|
||||||
|
this.syncFlushCallback = syncFlushCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.startTimer();
|
||||||
|
this.setupVisibilityListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startTimer(): void {
|
||||||
|
if (this.timer) return;
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
if (this.queue.length > 0 || this.pendingCounts.size > 0) {
|
||||||
|
this.flush();
|
||||||
|
}
|
||||||
|
}, this.flushInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupVisibilityListener(): void {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden && (this.queue.length > 0 || this.pendingCounts.size > 0)) {
|
||||||
|
this.flushSync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (this.queue.length > 0 || this.pendingCounts.size > 0) {
|
||||||
|
this.flushSync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('pagehide', () => {
|
||||||
|
if (this.queue.length > 0 || this.pendingCounts.size > 0) {
|
||||||
|
this.flushSync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkInfiniteLoop(fingerprint: string): boolean {
|
||||||
|
const currentTime = now();
|
||||||
|
const windowStart = currentTime - 1000;
|
||||||
|
|
||||||
|
if (!this.errorRateWindow[fingerprint]) {
|
||||||
|
this.errorRateWindow[fingerprint] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const window = this.errorRateWindow[fingerprint];
|
||||||
|
window.push(currentTime);
|
||||||
|
|
||||||
|
while (window.length > 0 && window[0] < windowStart) {
|
||||||
|
window.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.length > 10) {
|
||||||
|
if (!this.paused) {
|
||||||
|
console.warn('[LightSDK] Infinite loop detected, pausing SDK for 60s', { fingerprint, count: window.length });
|
||||||
|
this.eventBus.emit('error', new Error('Infinite loop detected'));
|
||||||
|
this.paused = true;
|
||||||
|
|
||||||
|
if (this.pauseTimer) {
|
||||||
|
clearTimeout(this.pauseTimer);
|
||||||
|
}
|
||||||
|
this.pauseTimer = setTimeout(() => {
|
||||||
|
this.paused = false;
|
||||||
|
this.errorRateWindow = {};
|
||||||
|
console.info('[LightSDK] Resumed after infinite loop detection');
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(event: SentryEvent): void {
|
||||||
|
if (this.paused) return;
|
||||||
|
|
||||||
|
const fingerprint = this.getDedupeKey(event);
|
||||||
|
|
||||||
|
if (fingerprint) {
|
||||||
|
if (this.checkInfiniteLoop(fingerprint)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTime = now();
|
||||||
|
const existing = this.dedupeMap.get(fingerprint);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (currentTime - existing.lastTime < 60000) {
|
||||||
|
if (existing.count >= 3) {
|
||||||
|
if (!this.pendingCounts.has(fingerprint)) {
|
||||||
|
this.pendingCounts.set(fingerprint, { count: 1, ts_start: currentTime, ts_end: currentTime });
|
||||||
|
} else {
|
||||||
|
const pending = this.pendingCounts.get(fingerprint)!;
|
||||||
|
pending.count++;
|
||||||
|
pending.ts_end = currentTime;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
existing.count++;
|
||||||
|
existing.lastTime = currentTime;
|
||||||
|
} else {
|
||||||
|
this.dedupeMap.set(fingerprint, { count: 1, lastTime: currentTime, firstReported: true });
|
||||||
|
this.pendingCounts.delete(fingerprint);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.dedupeMap.set(fingerprint, { count: 1, lastTime: currentTime, firstReported: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.queue.length >= this.maxSize) {
|
||||||
|
this.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queue.push(event);
|
||||||
|
this.eventBus.emit('event', event);
|
||||||
|
|
||||||
|
if (this.queue.length >= this.maxSize) {
|
||||||
|
this.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCountEvents(): SentryEvent[] {
|
||||||
|
const countEvents: SentryEvent[] = [];
|
||||||
|
|
||||||
|
for (const [fingerprint, data] of this.pendingCounts.entries()) {
|
||||||
|
// 构建聚合计数事件
|
||||||
|
const event: CountEvent = {
|
||||||
|
type: 'count',
|
||||||
|
fingerprint,
|
||||||
|
count: data.count,
|
||||||
|
ts_start: data.ts_start,
|
||||||
|
ts_end: data.ts_end,
|
||||||
|
timestamp: now(),
|
||||||
|
level: 'info',
|
||||||
|
};
|
||||||
|
countEvents.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空已上报的聚合计数
|
||||||
|
this.pendingCounts.clear();
|
||||||
|
return countEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDedupeKey(event: SentryEvent): string | null {
|
||||||
|
if (event.type === 'error') {
|
||||||
|
const errEvent = event as ErrorEvent;
|
||||||
|
return errEvent.fingerprint || errEvent.message;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async flush(): Promise<void> {
|
||||||
|
if (this.queue.length === 0 && this.pendingCounts.size === 0) return;
|
||||||
|
|
||||||
|
const events = this.queue.splice(0, this.queue.length);
|
||||||
|
const countEvents = this.buildCountEvents();
|
||||||
|
const allEvents = [...events, ...countEvents];
|
||||||
|
|
||||||
|
this.lastFlushTime = now();
|
||||||
|
this.eventBus.emit('report', allEvents);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.flushCallback(allEvents);
|
||||||
|
this.eventBus.emit('reported', allEvents);
|
||||||
|
} catch (e) {
|
||||||
|
events.forEach(evt => this.queue.unshift(evt));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushSync(): void {
|
||||||
|
if (this.queue.length === 0 && this.pendingCounts.size === 0) return;
|
||||||
|
|
||||||
|
const events = this.queue.splice(0, this.queue.length);
|
||||||
|
const countEvents = this.buildCountEvents();
|
||||||
|
const allEvents = [...events, ...countEvents];
|
||||||
|
|
||||||
|
this.lastFlushTime = now();
|
||||||
|
this.eventBus.emit('report', allEvents);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.syncFlushCallback(allEvents);
|
||||||
|
this.eventBus.emit('reported', allEvents);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[LightSDK] Sync flush failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size(): number {
|
||||||
|
return this.queue.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
if (this.pauseTimer) {
|
||||||
|
clearTimeout(this.pauseTimer);
|
||||||
|
this.pauseTimer = null;
|
||||||
|
}
|
||||||
|
this.dedupeMap.clear();
|
||||||
|
this.pendingCounts.clear();
|
||||||
|
this.errorRateWindow = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import type { LightPlugin, LightClient, SentryEvent } from '../types';
|
||||||
|
|
||||||
|
export class PluginManager {
|
||||||
|
private plugins: Map<string, LightPlugin> = new Map();
|
||||||
|
private client: LightClient;
|
||||||
|
|
||||||
|
constructor(client: LightClient) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
add(plugin: LightPlugin): void {
|
||||||
|
if (this.plugins.has(plugin.name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.plugins.set(plugin.name, plugin);
|
||||||
|
try {
|
||||||
|
plugin.setup(this.client);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[LightSDK] Plugin "${plugin.name}" setup error:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(name: string): void {
|
||||||
|
const plugin = this.plugins.get(name);
|
||||||
|
if (plugin) {
|
||||||
|
if (plugin.destroy) {
|
||||||
|
try {
|
||||||
|
plugin.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[LightSDK] Plugin "${name}" destroy error:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.plugins.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(name: string): LightPlugin | undefined {
|
||||||
|
return this.plugins.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
has(name: string): boolean {
|
||||||
|
return this.plugins.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBeforeReport(event: SentryEvent): SentryEvent | null {
|
||||||
|
let result: SentryEvent | null = event;
|
||||||
|
for (const plugin of this.plugins.values()) {
|
||||||
|
if (plugin.beforeReport) {
|
||||||
|
try {
|
||||||
|
const modified = plugin.beforeReport(result);
|
||||||
|
if (modified === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
result = modified;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[LightSDK] Plugin "${plugin.name}" beforeReport error:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyAfterReport(event: SentryEvent): void {
|
||||||
|
for (const plugin of this.plugins.values()) {
|
||||||
|
if (plugin.afterReport) {
|
||||||
|
try {
|
||||||
|
plugin.afterReport(event);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[LightSDK] Plugin "${plugin.name}" afterReport error:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
for (const plugin of this.plugins.values()) {
|
||||||
|
if (plugin.destroy) {
|
||||||
|
try {
|
||||||
|
plugin.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[LightSDK] Plugin "${plugin.name}" destroy error:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.plugins.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import Client from './core/Client';
|
||||||
|
import ErrorPlugin from './plugins/ErrorPlugin';
|
||||||
|
import PerformancePlugin from './plugins/PerformancePlugin';
|
||||||
|
import NetworkPlugin from './plugins/NetworkPlugin';
|
||||||
|
import BehaviorPlugin from './plugins/BehaviorPlugin';
|
||||||
|
import OfflinePlugin from './plugins/OfflinePlugin';
|
||||||
|
import type { LightConfig, LightClient, SentryEvent, EventLevel, UserInfo, Breadcrumb, LightPlugin, ErrorEvent, PerformanceEvent, NetworkEvent, BehaviorEvent, DSNInfo } from './types';
|
||||||
|
|
||||||
|
let globalClient: LightClient | null = null;
|
||||||
|
|
||||||
|
function init(config: LightConfig): LightClient {
|
||||||
|
const client = new Client(config);
|
||||||
|
|
||||||
|
const defaultPlugins: LightPlugin[] = [
|
||||||
|
new ErrorPlugin(),
|
||||||
|
new OfflinePlugin(),
|
||||||
|
];
|
||||||
|
|
||||||
|
const pluginNames = config.plugins?.filter(p => typeof p === 'string') as string[] || [];
|
||||||
|
|
||||||
|
if (pluginNames.includes('performance') || pluginNames.includes('all')) {
|
||||||
|
defaultPlugins.push(new PerformancePlugin());
|
||||||
|
}
|
||||||
|
if (pluginNames.includes('network') || pluginNames.includes('all')) {
|
||||||
|
defaultPlugins.push(new NetworkPlugin());
|
||||||
|
}
|
||||||
|
if (pluginNames.includes('behavior') || pluginNames.includes('all')) {
|
||||||
|
defaultPlugins.push(new BehaviorPlugin());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const plugin of defaultPlugins) {
|
||||||
|
(client as unknown as { use: (p: LightPlugin) => void }).use(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.plugins) {
|
||||||
|
for (const plugin of config.plugins) {
|
||||||
|
if (typeof plugin === 'object' && plugin !== null && 'name' in plugin && 'setup' in plugin) {
|
||||||
|
(client as unknown as { use: (p: LightPlugin) => void }).use(plugin as LightPlugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(client as unknown as { init: () => void }).init();
|
||||||
|
|
||||||
|
globalClient = client;
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClient(): LightClient | null {
|
||||||
|
return globalClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureException(error: Error | unknown): void {
|
||||||
|
globalClient?.captureException(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureMessage(message: string, level?: EventLevel): void {
|
||||||
|
globalClient?.captureMessage(message, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureEvent(event: Partial<SentryEvent> & { type: string }): void {
|
||||||
|
globalClient?.captureEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUser(user: UserInfo | null): void {
|
||||||
|
globalClient?.setUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTag(key: string, value: string): void {
|
||||||
|
globalClient?.setTag(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTags(tags: Record<string, string>): void {
|
||||||
|
globalClient?.setTags(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setExtra(key: string, value: unknown): void {
|
||||||
|
globalClient?.setExtra(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBreadcrumb(breadcrumb: Omit<Breadcrumb, 'timestamp'>): void {
|
||||||
|
globalClient?.addBreadcrumb(breadcrumb);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
await globalClient?.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function disable(): void {
|
||||||
|
globalClient?.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function enable(): void {
|
||||||
|
globalClient?.enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
init,
|
||||||
|
getClient,
|
||||||
|
captureException,
|
||||||
|
captureMessage,
|
||||||
|
captureEvent,
|
||||||
|
setUser,
|
||||||
|
setTag,
|
||||||
|
setTags,
|
||||||
|
setExtra,
|
||||||
|
addBreadcrumb,
|
||||||
|
flush,
|
||||||
|
disable,
|
||||||
|
enable,
|
||||||
|
Client,
|
||||||
|
ErrorPlugin,
|
||||||
|
PerformancePlugin,
|
||||||
|
NetworkPlugin,
|
||||||
|
BehaviorPlugin,
|
||||||
|
OfflinePlugin,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type {
|
||||||
|
LightConfig,
|
||||||
|
LightClient,
|
||||||
|
LightPlugin,
|
||||||
|
SentryEvent,
|
||||||
|
ErrorEvent,
|
||||||
|
PerformanceEvent,
|
||||||
|
NetworkEvent,
|
||||||
|
BehaviorEvent,
|
||||||
|
EventLevel,
|
||||||
|
UserInfo,
|
||||||
|
Breadcrumb,
|
||||||
|
DSNInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
init,
|
||||||
|
getClient,
|
||||||
|
captureException,
|
||||||
|
captureMessage,
|
||||||
|
captureEvent,
|
||||||
|
setUser,
|
||||||
|
setTag,
|
||||||
|
setTags,
|
||||||
|
setExtra,
|
||||||
|
addBreadcrumb,
|
||||||
|
flush,
|
||||||
|
disable,
|
||||||
|
enable,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
import type { LightPlugin, LightClient, BehaviorEvent, LightConfig } from '../types';
|
||||||
|
import { now, isObject } from '../utils/helper';
|
||||||
|
import { getPageUrl } from '../utils/env';
|
||||||
|
|
||||||
|
interface BehaviorPluginConfig {
|
||||||
|
capturePV?: boolean;
|
||||||
|
captureClick?: boolean;
|
||||||
|
captureRoute?: boolean;
|
||||||
|
captureDuration?: boolean;
|
||||||
|
captureScroll?: boolean;
|
||||||
|
clickThrottle?: number;
|
||||||
|
scrollThrottle?: number;
|
||||||
|
sampleRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BehaviorPlugin implements LightPlugin {
|
||||||
|
name = 'behavior';
|
||||||
|
version = '1.0.0';
|
||||||
|
|
||||||
|
private client!: LightClient;
|
||||||
|
private config: BehaviorPluginConfig = {
|
||||||
|
capturePV: true,
|
||||||
|
captureClick: true,
|
||||||
|
captureRoute: true,
|
||||||
|
captureDuration: true,
|
||||||
|
captureScroll: true,
|
||||||
|
clickThrottle: 300,
|
||||||
|
scrollThrottle: 1000,
|
||||||
|
sampleRate: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
private lastClickTime: number = 0;
|
||||||
|
private lastScrollTime: number = 0;
|
||||||
|
private maxScrollDepth: number = 0;
|
||||||
|
private pageEnterTime: number = 0;
|
||||||
|
private scrollReported: boolean = false;
|
||||||
|
|
||||||
|
setup(client: LightClient): void {
|
||||||
|
this.client = client;
|
||||||
|
this.loadConfig();
|
||||||
|
|
||||||
|
if (!this.shouldSample()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.config.capturePV) {
|
||||||
|
this.trackPV();
|
||||||
|
}
|
||||||
|
if (this.config.captureClick) {
|
||||||
|
this.trackClick();
|
||||||
|
}
|
||||||
|
if (this.config.captureRoute) {
|
||||||
|
this.trackRoute();
|
||||||
|
}
|
||||||
|
if (this.config.captureDuration) {
|
||||||
|
this.trackPageDuration();
|
||||||
|
}
|
||||||
|
if (this.config.captureScroll) {
|
||||||
|
this.trackScroll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadConfig(): void {
|
||||||
|
const clientConfig = this.client.config as LightConfig;
|
||||||
|
if (clientConfig && isObject(clientConfig.behavior)) {
|
||||||
|
this.config = { ...this.config, ...(clientConfig.behavior as object) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldSample(): boolean {
|
||||||
|
const rate = this.config.sampleRate ?? 0.1;
|
||||||
|
if (rate >= 1) return true;
|
||||||
|
if (rate <= 0) return false;
|
||||||
|
return Math.random() < rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getScrollDepth(): number {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') return 0;
|
||||||
|
|
||||||
|
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||||
|
const scrollHeight = document.documentElement.scrollHeight;
|
||||||
|
const clientHeight = document.documentElement.clientHeight;
|
||||||
|
|
||||||
|
if (scrollHeight <= clientHeight) return 100;
|
||||||
|
|
||||||
|
return Math.min(100, Math.round((scrollTop / (scrollHeight - clientHeight)) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackPV(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const url = getPageUrl();
|
||||||
|
const referrer = document.referrer;
|
||||||
|
|
||||||
|
this.reportBehavior({
|
||||||
|
sub_type: 'pv',
|
||||||
|
page_url: url,
|
||||||
|
referrer,
|
||||||
|
properties: {
|
||||||
|
title: document.title,
|
||||||
|
user_agent: navigator.userAgent,
|
||||||
|
screen_width: screen.width,
|
||||||
|
screen_height: screen.height,
|
||||||
|
viewport_width: window.innerWidth,
|
||||||
|
viewport_height: window.innerHeight,
|
||||||
|
language: navigator.language,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackClick(): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const currentTime = now();
|
||||||
|
const throttle = this.config.clickThrottle ?? 300;
|
||||||
|
if (currentTime - this.lastClickTime < throttle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastClickTime = currentTime;
|
||||||
|
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target || !target.tagName) return;
|
||||||
|
|
||||||
|
let selector = target.tagName.toLowerCase() || '';
|
||||||
|
if (target.id) {
|
||||||
|
selector += `#${target.id}`;
|
||||||
|
}
|
||||||
|
if (target.className && typeof target.className === 'string') {
|
||||||
|
const classes = target.className.split(' ').filter(Boolean).slice(0, 3).join('.');
|
||||||
|
if (classes) {
|
||||||
|
selector += `.${classes}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = target.textContent?.trim().slice(0, 50);
|
||||||
|
|
||||||
|
this.reportBehavior({
|
||||||
|
sub_type: 'click',
|
||||||
|
page_url: getPageUrl(),
|
||||||
|
properties: {
|
||||||
|
selector,
|
||||||
|
text: text || undefined,
|
||||||
|
tag: target.tagName?.toLowerCase(),
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackRoute(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
let lastUrl = getPageUrl();
|
||||||
|
|
||||||
|
const checkRoute = () => {
|
||||||
|
const currentUrl = getPageUrl();
|
||||||
|
if (currentUrl !== lastUrl) {
|
||||||
|
const from = lastUrl;
|
||||||
|
const to = currentUrl;
|
||||||
|
lastUrl = currentUrl;
|
||||||
|
|
||||||
|
this.reportBehavior({
|
||||||
|
sub_type: 'route',
|
||||||
|
page_url: to,
|
||||||
|
referrer: from,
|
||||||
|
properties: {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pageEnterTime = now();
|
||||||
|
this.maxScrollDepth = 0;
|
||||||
|
this.scrollReported = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalPushState = history.pushState;
|
||||||
|
const originalReplaceState = history.replaceState;
|
||||||
|
|
||||||
|
history.pushState = function () {
|
||||||
|
const result = originalPushState.apply(this, arguments as unknown as Parameters<typeof history.pushState>);
|
||||||
|
setTimeout(checkRoute, 0);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
history.replaceState = function () {
|
||||||
|
const result = originalReplaceState.apply(this, arguments as unknown as Parameters<typeof history.replaceState>);
|
||||||
|
setTimeout(checkRoute, 0);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
setTimeout(checkRoute, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
setTimeout(checkRoute, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackPageDuration(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
this.pageEnterTime = now();
|
||||||
|
|
||||||
|
const sendDuration = () => {
|
||||||
|
const duration = now() - this.pageEnterTime;
|
||||||
|
if (duration > 1000) {
|
||||||
|
if (this.config.captureScroll && !this.scrollReported) {
|
||||||
|
this.reportScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reportBehavior({
|
||||||
|
sub_type: 'duration',
|
||||||
|
page_url: getPageUrl(),
|
||||||
|
properties: {
|
||||||
|
duration,
|
||||||
|
max_scroll_depth: this.maxScrollDepth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
sendDuration();
|
||||||
|
} else {
|
||||||
|
this.pageEnterTime = now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', sendDuration);
|
||||||
|
window.addEventListener('pagehide', sendDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackScroll(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
const currentTime = now();
|
||||||
|
const throttle = this.config.scrollThrottle ?? 1000;
|
||||||
|
if (currentTime - this.lastScrollTime < throttle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastScrollTime = currentTime;
|
||||||
|
|
||||||
|
const depth = this.getScrollDepth();
|
||||||
|
if (depth > this.maxScrollDepth) {
|
||||||
|
this.maxScrollDepth = depth;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportScroll(): void {
|
||||||
|
if (this.scrollReported) return;
|
||||||
|
this.scrollReported = true;
|
||||||
|
|
||||||
|
this.reportBehavior({
|
||||||
|
sub_type: 'scroll',
|
||||||
|
page_url: getPageUrl(),
|
||||||
|
properties: {
|
||||||
|
max_depth: this.maxScrollDepth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportBehavior(data: Omit<BehaviorEvent, 'type' | 'level' | 'timestamp'>): void {
|
||||||
|
const event: BehaviorEvent = {
|
||||||
|
type: 'behavior',
|
||||||
|
level: 'info',
|
||||||
|
timestamp: now(),
|
||||||
|
tags: {
|
||||||
|
sub_type: data.sub_type,
|
||||||
|
page_url: getPageUrl(),
|
||||||
|
},
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.client.captureEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BehaviorPlugin;
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
import type { LightPlugin, LightClient, ErrorEvent, StackFrame, LightConfig } from '../types';
|
||||||
|
import { now, isObject } from '../utils/helper';
|
||||||
|
import { computeFingerprint } from '../utils/hash';
|
||||||
|
import { shouldIgnoreUrl } from '../utils/env';
|
||||||
|
|
||||||
|
interface ErrorPluginConfig {
|
||||||
|
maxStackFrames?: number;
|
||||||
|
captureNodeModules?: boolean;
|
||||||
|
captureColumn?: boolean;
|
||||||
|
relativePathOnly?: boolean;
|
||||||
|
ignoreErrors?: (string | RegExp)[];
|
||||||
|
ignoreUrls?: (string | RegExp)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorPlugin implements LightPlugin {
|
||||||
|
name = 'error';
|
||||||
|
version = '1.0.0';
|
||||||
|
|
||||||
|
private client!: LightClient;
|
||||||
|
private config: ErrorPluginConfig = {
|
||||||
|
maxStackFrames: 5,
|
||||||
|
captureNodeModules: false,
|
||||||
|
captureColumn: true,
|
||||||
|
relativePathOnly: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
setup(client: LightClient): void {
|
||||||
|
this.client = client;
|
||||||
|
this.loadConfig();
|
||||||
|
this.setupGlobalError();
|
||||||
|
this.setupUnhandledRejection();
|
||||||
|
this.setupResourceError();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadConfig(): void {
|
||||||
|
const clientConfig = this.client.config as LightConfig;
|
||||||
|
if (clientConfig && isObject(clientConfig.error)) {
|
||||||
|
this.config = { ...this.config, ...(clientConfig.error as object) };
|
||||||
|
}
|
||||||
|
if (clientConfig.ignoreErrors) {
|
||||||
|
this.config.ignoreErrors = [...(this.config.ignoreErrors || []), ...clientConfig.ignoreErrors];
|
||||||
|
}
|
||||||
|
if (clientConfig.ignoreUrls) {
|
||||||
|
this.config.ignoreUrls = [...(this.config.ignoreUrls || []), ...clientConfig.ignoreUrls];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldIgnoreError(message: string): boolean {
|
||||||
|
if (!this.config.ignoreErrors || !message) return false;
|
||||||
|
return this.config.ignoreErrors.some(pattern => {
|
||||||
|
if (typeof pattern === 'string') {
|
||||||
|
return message.includes(pattern);
|
||||||
|
}
|
||||||
|
return pattern.test(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldIgnoreScriptUrl(url: string): boolean {
|
||||||
|
return shouldIgnoreUrl(url, this.config.ignoreUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupGlobalError(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const originalOnError = window.onerror;
|
||||||
|
|
||||||
|
window.onerror = (message, url, lineno, colno, error) => {
|
||||||
|
try {
|
||||||
|
if (originalOnError) {
|
||||||
|
originalOnError.call(window, message, url, lineno, colno, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorEvent = this.buildErrorEvent(
|
||||||
|
error || (message as string),
|
||||||
|
url as string,
|
||||||
|
lineno as number,
|
||||||
|
colno as number
|
||||||
|
);
|
||||||
|
|
||||||
|
if (errorEvent) {
|
||||||
|
this.client.captureEvent(errorEvent);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[LightSDK] ErrorPlugin global error handler error:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupUnhandledRejection(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
try {
|
||||||
|
const reason = event.reason;
|
||||||
|
const errorEvent = this.buildErrorEvent(reason);
|
||||||
|
|
||||||
|
if (errorEvent) {
|
||||||
|
if (errorEvent.type === 'error') {
|
||||||
|
(errorEvent as ErrorEvent).tags = {
|
||||||
|
...((errorEvent as ErrorEvent).tags || {}),
|
||||||
|
unhandled: 'true',
|
||||||
|
promise: 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client.captureEvent(errorEvent);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[LightSDK] ErrorPlugin unhandledrejection handler error:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupResourceError(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const tagName = (target as HTMLElement).tagName?.toLowerCase();
|
||||||
|
const src = (target as HTMLImageElement).src || (target as HTMLLinkElement).href;
|
||||||
|
|
||||||
|
if (tagName && src && ['img', 'script', 'link', 'audio', 'video'].includes(tagName)) {
|
||||||
|
try {
|
||||||
|
this.client.captureEvent({
|
||||||
|
type: 'error',
|
||||||
|
level: 'warning',
|
||||||
|
message: `Resource load failed: ${tagName} ${src}`,
|
||||||
|
timestamp: now(),
|
||||||
|
tags: {
|
||||||
|
resource_type: tagName,
|
||||||
|
resource_url: src,
|
||||||
|
},
|
||||||
|
exception: {
|
||||||
|
type: 'ResourceError',
|
||||||
|
value: `Failed to load ${tagName} resource`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[LightSDK] ErrorPlugin resource error handler error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildErrorEvent(error: Error | unknown, url?: string, lineno?: number, colno?: number): Partial<ErrorEvent> & { type: string } | null {
|
||||||
|
const timestamp = now();
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (this.shouldIgnoreError(error.message)) return null;
|
||||||
|
|
||||||
|
const frames = this.parseStackTrace(error.stack);
|
||||||
|
const fingerprint = computeFingerprint(error.name, error.message, frames);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'error',
|
||||||
|
level: 'error',
|
||||||
|
message: error.message,
|
||||||
|
timestamp,
|
||||||
|
exception: {
|
||||||
|
type: error.name,
|
||||||
|
value: error.message,
|
||||||
|
stacktrace: {
|
||||||
|
frames: frames.slice(0, this.config.maxStackFrames || 5),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fingerprint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = String(error);
|
||||||
|
if (this.shouldIgnoreError(message)) return null;
|
||||||
|
|
||||||
|
const frames: StackFrame[] = [];
|
||||||
|
if (url) {
|
||||||
|
frames.push({
|
||||||
|
filename: url,
|
||||||
|
lineno,
|
||||||
|
colno: this.config.captureColumn ? colno : undefined,
|
||||||
|
in_app: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'error',
|
||||||
|
level: 'error',
|
||||||
|
message,
|
||||||
|
timestamp,
|
||||||
|
exception: {
|
||||||
|
type: 'Error',
|
||||||
|
value: message,
|
||||||
|
stacktrace: {
|
||||||
|
frames,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseStackTrace(stack?: string): StackFrame[] {
|
||||||
|
if (!stack) return [];
|
||||||
|
|
||||||
|
const frames: StackFrame[] = [];
|
||||||
|
const lines = stack.split('\n');
|
||||||
|
const origin = typeof location !== 'undefined' ? `${location.protocol}//${location.host}` : '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/^\s*at\s*(.+?)\s*\((.+?):(\d+):(\d+)\)\s*$/);
|
||||||
|
if (match) {
|
||||||
|
const [, fn, filename, lineNum, colNum] = match;
|
||||||
|
|
||||||
|
const isNodeModules = filename.includes('node_modules');
|
||||||
|
if (!this.config.captureNodeModules && isNodeModules) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalFilename = filename;
|
||||||
|
if (this.config.relativePathOnly && origin && filename.startsWith(origin)) {
|
||||||
|
finalFilename = filename.substring(origin.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
frames.push({
|
||||||
|
filename: finalFilename,
|
||||||
|
function: fn,
|
||||||
|
lineno: parseInt(lineNum, 10),
|
||||||
|
colno: this.config.captureColumn ? parseInt(colNum, 10) : undefined,
|
||||||
|
in_app: !isNodeModules,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const urlMatch = line.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/);
|
||||||
|
if (urlMatch) {
|
||||||
|
const [, filename, lineNum, colNum] = urlMatch;
|
||||||
|
|
||||||
|
const isNodeModules = filename.includes('node_modules');
|
||||||
|
if (!this.config.captureNodeModules && isNodeModules) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalFilename = filename;
|
||||||
|
if (this.config.relativePathOnly && origin && filename.startsWith(origin)) {
|
||||||
|
finalFilename = filename.substring(origin.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
frames.push({
|
||||||
|
filename: finalFilename,
|
||||||
|
lineno: parseInt(lineNum, 10),
|
||||||
|
colno: this.config.captureColumn ? parseInt(colNum, 10) : undefined,
|
||||||
|
in_app: !isNodeModules,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frames.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorPlugin;
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
import type { LightPlugin, LightClient, NetworkEvent, LightConfig } from '../types';
|
||||||
|
import { now, isObject } from '../utils/helper';
|
||||||
|
import { sanitizeUrl, shouldIgnoreUrl } from '../utils/env';
|
||||||
|
|
||||||
|
interface NetworkPluginConfig {
|
||||||
|
ignoreUrls?: (string | RegExp)[];
|
||||||
|
captureSuccess?: boolean;
|
||||||
|
captureBody?: boolean;
|
||||||
|
captureRequestBody?: boolean;
|
||||||
|
captureResponseBody?: boolean;
|
||||||
|
captureRequestHeaders?: string[];
|
||||||
|
captureResponseHeaders?: string[];
|
||||||
|
errorSampleRate?: number;
|
||||||
|
successSampleRate?: number;
|
||||||
|
slowThreshold?: number;
|
||||||
|
slowSampleRate?: number;
|
||||||
|
ignoreStatusCodes?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class NetworkPlugin implements LightPlugin {
|
||||||
|
name = 'network';
|
||||||
|
version = '1.0.0';
|
||||||
|
|
||||||
|
private client!: LightClient;
|
||||||
|
private config: NetworkPluginConfig = {
|
||||||
|
captureSuccess: false,
|
||||||
|
captureBody: false,
|
||||||
|
captureRequestBody: false,
|
||||||
|
captureResponseBody: false,
|
||||||
|
captureRequestHeaders: ['content-type', 'user-agent'],
|
||||||
|
captureResponseHeaders: ['content-type', 'content-length'],
|
||||||
|
errorSampleRate: 1.0,
|
||||||
|
successSampleRate: 0.01,
|
||||||
|
slowThreshold: 3000,
|
||||||
|
slowSampleRate: 1.0,
|
||||||
|
ignoreStatusCodes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
private originalFetch!: typeof fetch;
|
||||||
|
private originalXHROpen!: typeof XMLHttpRequest.prototype.open;
|
||||||
|
private originalXHRSend!: typeof XMLHttpRequest.prototype.send;
|
||||||
|
|
||||||
|
setup(client: LightClient): void {
|
||||||
|
this.client = client;
|
||||||
|
this.loadConfig();
|
||||||
|
this.patchFetch();
|
||||||
|
this.patchXHR();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadConfig(): void {
|
||||||
|
const clientConfig = this.client.config as LightConfig;
|
||||||
|
if (clientConfig && isObject(clientConfig.network)) {
|
||||||
|
this.config = { ...this.config, ...(clientConfig.network as object) };
|
||||||
|
}
|
||||||
|
if (clientConfig.ignoreUrls) {
|
||||||
|
this.config.ignoreUrls = [...(this.config.ignoreUrls || []), ...clientConfig.ignoreUrls];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldIgnore(url: string): boolean {
|
||||||
|
return shouldIgnoreUrl(url, this.config.ignoreUrls || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldSample(isError: boolean, duration: number): boolean {
|
||||||
|
let rate = isError ? (this.config.errorSampleRate ?? 1.0) : (this.config.successSampleRate ?? 0.01);
|
||||||
|
|
||||||
|
if (!isError && duration >= (this.config.slowThreshold ?? 3000)) {
|
||||||
|
rate = this.config.slowSampleRate ?? 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rate >= 1) return true;
|
||||||
|
if (rate <= 0) return false;
|
||||||
|
return Math.random() < rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchFetch(): void {
|
||||||
|
if (typeof fetch === 'undefined') return;
|
||||||
|
|
||||||
|
this.originalFetch = window.fetch;
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
window.fetch = function (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||||
|
const startTime = now();
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
const method = init?.method || 'GET';
|
||||||
|
|
||||||
|
if (self.shouldIgnore(url)) {
|
||||||
|
return self.originalFetch.call(window, input, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedUrl = sanitizeUrl(url);
|
||||||
|
const promise = self.originalFetch.call(window, input, init);
|
||||||
|
|
||||||
|
promise.then(
|
||||||
|
(response) => {
|
||||||
|
const duration = now() - startTime;
|
||||||
|
const isSuccess = response.ok;
|
||||||
|
const statusCode = response.status;
|
||||||
|
|
||||||
|
if (self.config.ignoreStatusCodes?.includes(statusCode)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self.shouldSample(!isSuccess, duration)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSuccess || self.config.captureSuccess) {
|
||||||
|
self.reportNetwork({
|
||||||
|
sub_type: 'fetch',
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
url: sanitizedUrl,
|
||||||
|
status_code: statusCode,
|
||||||
|
duration,
|
||||||
|
success: isSuccess,
|
||||||
|
error: !isSuccess ? `HTTP ${statusCode}` : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
const duration = now() - startTime;
|
||||||
|
|
||||||
|
if (self.shouldSample(true, duration)) {
|
||||||
|
self.reportNetwork({
|
||||||
|
sub_type: 'fetch',
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
url: sanitizedUrl,
|
||||||
|
duration,
|
||||||
|
success: false,
|
||||||
|
error: error?.message || 'Network error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private patchXHR(): void {
|
||||||
|
if (typeof XMLHttpRequest === 'undefined') return;
|
||||||
|
|
||||||
|
this.originalXHROpen = XMLHttpRequest.prototype.open;
|
||||||
|
this.originalXHRSend = XMLHttpRequest.prototype.send;
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
XMLHttpRequest.prototype.open = function (method: string, url: string | URL) {
|
||||||
|
(this as { _lightMethod?: string })._lightMethod = method;
|
||||||
|
(this as { _lightUrl?: string })._lightUrl = typeof url === 'string' ? url : url.toString();
|
||||||
|
return self.originalXHROpen.apply(this, arguments as unknown as Parameters<typeof XMLHttpRequest.prototype.open>);
|
||||||
|
};
|
||||||
|
|
||||||
|
XMLHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null) {
|
||||||
|
const startTime = now();
|
||||||
|
const method = (this as { _lightMethod?: string })._lightMethod || 'GET';
|
||||||
|
const url = (this as { _lightUrl?: string })._lightUrl || '';
|
||||||
|
|
||||||
|
if (self.shouldIgnore(url)) {
|
||||||
|
return self.originalXHRSend.apply(this, arguments as unknown as Parameters<typeof XMLHttpRequest.prototype.send>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedUrl = sanitizeUrl(url);
|
||||||
|
let requestSize = 0;
|
||||||
|
if (body && typeof body === 'string') {
|
||||||
|
requestSize = body.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLoadEnd = () => {
|
||||||
|
const duration = now() - startTime;
|
||||||
|
const status = this.status;
|
||||||
|
const isSuccess = status >= 200 && status < 300;
|
||||||
|
|
||||||
|
if (self.config.ignoreStatusCodes?.includes(status)) {
|
||||||
|
this.removeEventListener('loadend', onLoadEnd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self.shouldSample(!isSuccess, duration)) {
|
||||||
|
this.removeEventListener('loadend', onLoadEnd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSuccess && !self.config.captureSuccess) {
|
||||||
|
this.removeEventListener('loadend', onLoadEnd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let responseSize = 0;
|
||||||
|
try {
|
||||||
|
const sizeHeader = this.getResponseHeader('content-length');
|
||||||
|
if (sizeHeader) {
|
||||||
|
responseSize = parseInt(sizeHeader, 10);
|
||||||
|
}
|
||||||
|
if (!responseSize && this.responseText) {
|
||||||
|
responseSize = this.responseText.length;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
self.reportNetwork({
|
||||||
|
sub_type: 'xhr',
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
url: sanitizedUrl,
|
||||||
|
status_code: status,
|
||||||
|
duration,
|
||||||
|
request_size: requestSize || undefined,
|
||||||
|
response_size: responseSize || undefined,
|
||||||
|
success: isSuccess,
|
||||||
|
error: !isSuccess ? `HTTP ${status}` : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.removeEventListener('loadend', onLoadEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addEventListener('loadend', onLoadEnd);
|
||||||
|
|
||||||
|
return self.originalXHRSend.apply(this, arguments as unknown as Parameters<typeof XMLHttpRequest.prototype.send>);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportNetwork(data: Omit<NetworkEvent, 'type' | 'level' | 'timestamp'>): void {
|
||||||
|
const event: NetworkEvent = {
|
||||||
|
type: 'network',
|
||||||
|
level: data.success ? 'info' : 'error',
|
||||||
|
timestamp: now(),
|
||||||
|
tags: {
|
||||||
|
method: data.method,
|
||||||
|
sub_type: data.sub_type,
|
||||||
|
success: String(data.success),
|
||||||
|
},
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.client.captureEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this.originalFetch) {
|
||||||
|
window.fetch = this.originalFetch;
|
||||||
|
}
|
||||||
|
if (this.originalXHROpen) {
|
||||||
|
XMLHttpRequest.prototype.open = this.originalXHROpen;
|
||||||
|
}
|
||||||
|
if (this.originalXHRSend) {
|
||||||
|
XMLHttpRequest.prototype.send = this.originalXHRSend;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NetworkPlugin;
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
import type { LightPlugin, LightClient, SentryEvent, LightConfig } from '../types';
|
||||||
|
import { now } from '../utils/helper';
|
||||||
|
|
||||||
|
const DB_NAME = 'light-sentry-offline';
|
||||||
|
const STORE_NAME = 'events';
|
||||||
|
const MAX_EVENTS = 1000;
|
||||||
|
|
||||||
|
interface OfflineEvent {
|
||||||
|
id: string;
|
||||||
|
event: SentryEvent;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OfflinePlugin implements LightPlugin {
|
||||||
|
name = 'offline';
|
||||||
|
version = '1.0.0';
|
||||||
|
|
||||||
|
private client!: LightClient;
|
||||||
|
private config: { maxEvents?: number } = { maxEvents: MAX_EVENTS };
|
||||||
|
private db: IDBDatabase | null = null;
|
||||||
|
private isOnline: boolean = true;
|
||||||
|
private isSyncing: boolean = false;
|
||||||
|
private pendingEvents: SentryEvent[] = [];
|
||||||
|
|
||||||
|
setup(client: LightClient): void {
|
||||||
|
this.client = client;
|
||||||
|
this.loadConfig();
|
||||||
|
this.initDatabase();
|
||||||
|
this.setupNetworkListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadConfig(): void {
|
||||||
|
const clientConfig = this.client.config as LightConfig;
|
||||||
|
if (clientConfig && typeof clientConfig.offline === 'object') {
|
||||||
|
this.config = { ...this.config, ...(clientConfig.offline as object) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initDatabase(): Promise<void> {
|
||||||
|
if (typeof indexedDB === 'undefined') {
|
||||||
|
console.warn('[LightSDK] OfflinePlugin: IndexedDB not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, 1);
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.warn('[LightSDK] OfflinePlugin: Failed to open IndexedDB');
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
this.db = request.result;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||||
|
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupNetworkListeners(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
this.isOnline = navigator.onLine;
|
||||||
|
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
console.info('[LightSDK] OfflinePlugin: Network online');
|
||||||
|
this.isOnline = true;
|
||||||
|
this.syncPendingEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('offline', () => {
|
||||||
|
console.info('[LightSDK] OfflinePlugin: Network offline');
|
||||||
|
this.isOnline = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveToIndexedDB(event: SentryEvent): Promise<void> {
|
||||||
|
if (!this.db) return;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db!.transaction([STORE_NAME], 'readwrite');
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
|
||||||
|
// 先检查数量
|
||||||
|
const countRequest = store.count();
|
||||||
|
countRequest.onsuccess = async () => {
|
||||||
|
const count = countRequest.result;
|
||||||
|
if (count >= (this.config.maxEvents || MAX_EVENTS)) {
|
||||||
|
// 删除最旧的事件
|
||||||
|
await this.deleteOldestEvents(count - (this.config.maxEvents || MAX_EVENTS) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const offlineEvent: OfflineEvent = {
|
||||||
|
id: this.generateId(),
|
||||||
|
event: {
|
||||||
|
...event,
|
||||||
|
offline: true, // 标记为离线事件
|
||||||
|
} as SentryEvent,
|
||||||
|
timestamp: now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRequest = store.add(offlineEvent);
|
||||||
|
addRequest.onsuccess = () => resolve();
|
||||||
|
addRequest.onerror = () => reject(addRequest.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteOldestEvents(count: number): Promise<void> {
|
||||||
|
if (!this.db || count <= 0) return;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db!.transaction([STORE_NAME], 'readwrite');
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
const index = store.index('timestamp');
|
||||||
|
|
||||||
|
const cursorRequest = index.openCursor();
|
||||||
|
let deleted = 0;
|
||||||
|
|
||||||
|
cursorRequest.onsuccess = (event) => {
|
||||||
|
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
|
||||||
|
if (cursor && deleted < count) {
|
||||||
|
cursor.delete();
|
||||||
|
deleted++;
|
||||||
|
cursor.continue();
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cursorRequest.onerror = () => reject(cursorRequest.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAllStoredEvents(): Promise<OfflineEvent[]> {
|
||||||
|
if (!this.db) return [];
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db!.transaction([STORE_NAME], 'readonly');
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
const index = store.index('timestamp');
|
||||||
|
const request = index.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const events = request.result || [];
|
||||||
|
// 按时间排序
|
||||||
|
events.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
resolve(events);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearStoredEvents(): Promise<void> {
|
||||||
|
if (!this.db) return;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db!.transaction([STORE_NAME], 'readwrite');
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
const request = store.clear();
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncPendingEvents(): Promise<void> {
|
||||||
|
if (!this.isOnline || this.isSyncing) return;
|
||||||
|
|
||||||
|
this.isSyncing = true;
|
||||||
|
console.info('[LightSDK] OfflinePlugin: Syncing pending events');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedEvents = await this.getAllStoredEvents();
|
||||||
|
|
||||||
|
if (storedEvents.length === 0) {
|
||||||
|
console.info('[LightSDK] OfflinePlugin: No pending events to sync');
|
||||||
|
this.isSyncing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分批上报,每次最多 50 条
|
||||||
|
const batchSize = 50;
|
||||||
|
for (let i = 0; i < storedEvents.length; i += batchSize) {
|
||||||
|
const batch = storedEvents.slice(i, i + batchSize);
|
||||||
|
const events = batch.map(e => e.event);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用客户端的上报接口
|
||||||
|
const flush = async () => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
// 创建一个临时的上报函数
|
||||||
|
// 注意:这里需要通过 EventBus 触发上报
|
||||||
|
this.client.emit('sync:offline', events);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
console.info(`[LightSDK] OfflinePlugin: Synced ${batch.length} events`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LightSDK] OfflinePlugin: Failed to sync batch', error);
|
||||||
|
// 继续同步其他批次
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空已同步的事件
|
||||||
|
await this.clearStoredEvents();
|
||||||
|
console.info('[LightSDK] OfflinePlugin: All pending events synced');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LightSDK] OfflinePlugin: Sync failed', error);
|
||||||
|
} finally {
|
||||||
|
this.isSyncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeReport(event: SentryEvent): SentryEvent | null {
|
||||||
|
if (!this.isOnline) {
|
||||||
|
// 离线时保存到 IndexedDB
|
||||||
|
this.saveToIndexedDB(event).catch(err => {
|
||||||
|
console.error('[LightSDK] OfflinePlugin: Failed to save event offline', err);
|
||||||
|
});
|
||||||
|
// 返回 null 表示不上报(因为已经缓存了)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在线时,标记为非离线事件
|
||||||
|
const onlineEvent = { ...event };
|
||||||
|
delete (onlineEvent as Record<string, unknown>).offline;
|
||||||
|
return onlineEvent as SentryEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
// 清理资源
|
||||||
|
if (this.db) {
|
||||||
|
this.db.close();
|
||||||
|
this.db = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OfflinePlugin;
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
import type { LightPlugin, LightClient, PerformanceEvent, LightConfig } from '../types';
|
||||||
|
import { now, isObject } from '../utils/helper';
|
||||||
|
|
||||||
|
interface PerformancePluginConfig {
|
||||||
|
sampleRate?: number;
|
||||||
|
captureLongTasks?: boolean;
|
||||||
|
captureResources?: boolean;
|
||||||
|
resourceSampleRate?: number;
|
||||||
|
longTaskSampleRate?: number;
|
||||||
|
captureNavigation?: boolean;
|
||||||
|
captureFP?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PerformancePlugin implements LightPlugin {
|
||||||
|
name = 'performance';
|
||||||
|
version = '1.0.0';
|
||||||
|
|
||||||
|
private client!: LightClient;
|
||||||
|
private config: PerformancePluginConfig = {
|
||||||
|
sampleRate: 0.1,
|
||||||
|
captureLongTasks: true,
|
||||||
|
captureResources: false,
|
||||||
|
resourceSampleRate: 0.01,
|
||||||
|
longTaskSampleRate: 0.05,
|
||||||
|
captureNavigation: true,
|
||||||
|
captureFP: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
setup(client: LightClient): void {
|
||||||
|
this.client = client;
|
||||||
|
this.loadConfig();
|
||||||
|
|
||||||
|
if (!this.shouldSample('default')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.observeWebVitals();
|
||||||
|
this.observeLongTasks();
|
||||||
|
this.observeNavigation();
|
||||||
|
this.observeResources();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadConfig(): void {
|
||||||
|
const clientConfig = this.client.config as LightConfig;
|
||||||
|
if (clientConfig && isObject(clientConfig.performance)) {
|
||||||
|
this.config = { ...this.config, ...(clientConfig.performance as object) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldSample(type: 'default' | 'resource' | 'longtask' = 'default'): boolean {
|
||||||
|
let rate = this.config.sampleRate ?? 0.1;
|
||||||
|
if (type === 'resource') {
|
||||||
|
rate = this.config.resourceSampleRate ?? 0.01;
|
||||||
|
} else if (type === 'longtask') {
|
||||||
|
rate = this.config.longTaskSampleRate ?? 0.05;
|
||||||
|
}
|
||||||
|
if (rate >= 1) return true;
|
||||||
|
if (rate <= 0) return false;
|
||||||
|
return Math.random() < rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private observeWebVitals(): void {
|
||||||
|
if (typeof PerformanceObserver === 'undefined') return;
|
||||||
|
|
||||||
|
if (this.config.captureFP) {
|
||||||
|
this.observeFP();
|
||||||
|
}
|
||||||
|
this.observeLCP();
|
||||||
|
this.observeFID();
|
||||||
|
this.observeCLS();
|
||||||
|
this.observeFCP();
|
||||||
|
this.observeTTFB();
|
||||||
|
}
|
||||||
|
|
||||||
|
private observeFP(): void {
|
||||||
|
try {
|
||||||
|
const po = new PerformanceObserver((entryList) => {
|
||||||
|
const entries = entryList.getEntriesByName('first-paint');
|
||||||
|
if (entries.length > 0) {
|
||||||
|
this.reportMetric('FP', entries[0].startTime, 'ms');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
po.observe({ type: 'paint', buffered: true });
|
||||||
|
} catch {
|
||||||
|
// FP not supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private observeLCP(): void {
|
||||||
|
try {
|
||||||
|
const po = new PerformanceObserver((entryList) => {
|
||||||
|
const entries = entryList.getEntries();
|
||||||
|
const lastEntry = entries[entries.length - 1] as PerformanceEntry & { renderTime?: number; loadTime?: number };
|
||||||
|
if (lastEntry) {
|
||||||
|
const value = lastEntry.renderTime || lastEntry.loadTime || lastEntry.startTime;
|
||||||
|
this.reportMetric('LCP', value, 'ms');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
po.observe({ type: 'largest-contentful-paint', buffered: true });
|
||||||
|
} catch {
|
||||||
|
// LCP not supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private observeFID(): void {
|
||||||
|
try {
|
||||||
|
const po = new PerformanceObserver((entryList) => {
|
||||||
|
const entries = entryList.getEntries() as PerformanceEventTiming[];
|
||||||
|
if (entries.length > 0) {
|
||||||
|
const firstEntry = entries[0];
|
||||||
|
const value = firstEntry.processingStart - firstEntry.startTime;
|
||||||
|
this.reportMetric('FID', value, 'ms');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
po.observe({ type: 'first-input', buffered: true });
|
||||||
|
} catch {
|
||||||
|
// FID not supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private observeCLS(): void {
|
||||||
|
try {
|
||||||
|
let clsValue = 0;
|
||||||
|
let sessionValue = 0;
|
||||||
|
let sessionEntries: PerformanceEntry[] = [];
|
||||||
|
|
||||||
|
const po = new PerformanceObserver((entryList) => {
|
||||||
|
const entries = entryList.getEntries() as PerformanceEntry & { hadRecentInput?: boolean; value?: number }[];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.hadRecentInput) {
|
||||||
|
const firstSessionEntry = sessionEntries[0];
|
||||||
|
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
|
||||||
|
|
||||||
|
if (
|
||||||
|
sessionValue &&
|
||||||
|
entry.startTime - (lastSessionEntry as { endTime?: number }).endTime! < 1000 &&
|
||||||
|
entry.startTime - firstSessionEntry.startTime < 5000
|
||||||
|
) {
|
||||||
|
sessionValue += entry.value || 0;
|
||||||
|
sessionEntries.push(entry as PerformanceEntry);
|
||||||
|
} else {
|
||||||
|
sessionValue = entry.value || 0;
|
||||||
|
sessionEntries = [entry as PerformanceEntry];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionValue > clsValue) {
|
||||||
|
clsValue = sessionValue;
|
||||||
|
this.reportMetric('CLS', clsValue, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
po.observe({ type: 'layout-shift', buffered: true });
|
||||||
|
} catch {
|
||||||
|
// CLS not supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private observeFCP(): void {
|
||||||
|
try {
|
||||||
|
const po = new PerformanceObserver((entryList) => {
|
||||||
|
const entries = entryList.getEntriesByName('first-contentful-paint');
|
||||||
|
if (entries.length > 0) {
|
||||||
|
this.reportMetric('FCP', entries[0].startTime, 'ms');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
po.observe({ type: 'paint', buffered: true });
|
||||||
|
} catch {
|
||||||
|
// FCP not supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private observeTTFB(): void {
|
||||||
|
try {
|
||||||
|
const po = new PerformanceObserver((entryList) => {
|
||||||
|
const navEntries = entryList.getEntriesByType('navigation') as PerformanceNavigationTiming[];
|
||||||
|
if (navEntries.length > 0) {
|
||||||
|
const navEntry = navEntries[0];
|
||||||
|
const ttfb = navEntry.responseStart - navEntry.requestStart;
|
||||||
|
this.reportMetric('TTFB', Math.max(0, ttfb), 'ms');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
po.observe({ type: 'navigation', buffered: true });
|
||||||
|
} catch {
|
||||||
|
// Navigation timing not supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private observeLongTasks(): void {
|
||||||
|
if (!this.config.captureLongTasks) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const po = new PerformanceObserver((entryList) => {
|
||||||
|
const entries = entryList.getEntries();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (this.shouldSample('longtask')) {
|
||||||
|
this.reportMetric('longtask', entry.duration, 'ms');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
po.observe({ type: 'longtask', buffered: true });
|
||||||
|
} catch {
|
||||||
|
// Long tasks not supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private observeNavigation(): void {
|
||||||
|
if (!this.config.captureNavigation) return;
|
||||||
|
if (typeof performance === 'undefined') return;
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const timing = performance.timing;
|
||||||
|
if (!timing) return;
|
||||||
|
|
||||||
|
const navigationStart = timing.navigationStart;
|
||||||
|
const metrics: Record<string, number> = {
|
||||||
|
dom_ready: timing.domContentLoadedEventEnd - navigationStart,
|
||||||
|
load_time: timing.loadEventEnd - navigationStart,
|
||||||
|
dns: timing.domainLookupEnd - timing.domainLookupStart,
|
||||||
|
tcp: timing.connectEnd - timing.connectStart,
|
||||||
|
ssl: timing.secureConnectionStart > 0 ? timing.connectEnd - timing.secureConnectionStart : 0,
|
||||||
|
ttfb: timing.responseStart - timing.requestStart,
|
||||||
|
download: timing.responseEnd - timing.responseStart,
|
||||||
|
dom_parse: timing.domInteractive - timing.responseEnd,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [name, value] of Object.entries(metrics)) {
|
||||||
|
if (value > 0) {
|
||||||
|
this.reportMetric(name, value, 'ms');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private observeResources(): void {
|
||||||
|
if (!this.config.captureResources) return;
|
||||||
|
if (typeof performance === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const po = new PerformanceObserver((entryList) => {
|
||||||
|
if (!this.shouldSample('resource')) return;
|
||||||
|
|
||||||
|
const entries = entryList.getEntriesByType('resource') as PerformanceResourceTiming[];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.duration > 1000) {
|
||||||
|
this.reportMetric('resource_slow', entry.duration, 'ms', {
|
||||||
|
resource_name: entry.name.substring(0, 200),
|
||||||
|
resource_type: entry.initiatorType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
po.observe({ type: 'resource', buffered: true });
|
||||||
|
} catch {
|
||||||
|
// Resource timing not supported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRating(name: string, value: number): 'good' | 'needs-improvement' | 'poor' {
|
||||||
|
const thresholds: Record<string, { good: number; poor: number }> = {
|
||||||
|
LCP: { good: 2500, poor: 4000 },
|
||||||
|
FCP: { good: 1800, poor: 3000 },
|
||||||
|
FP: { good: 1800, poor: 3000 },
|
||||||
|
FID: { good: 100, poor: 300 },
|
||||||
|
CLS: { good: 0.1, poor: 0.25 },
|
||||||
|
TTFB: { good: 800, poor: 1800 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const threshold = thresholds[name];
|
||||||
|
if (!threshold) return 'good';
|
||||||
|
|
||||||
|
if (value <= threshold.good) return 'good';
|
||||||
|
if (value <= threshold.poor) return 'needs-improvement';
|
||||||
|
return 'poor';
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportMetric(name: string, value: number, unit: string, extraTags: Record<string, string> = {}): void {
|
||||||
|
const rating = this.getRating(name, value);
|
||||||
|
|
||||||
|
const event: PerformanceEvent = {
|
||||||
|
type: 'performance',
|
||||||
|
level: 'info',
|
||||||
|
metric: name,
|
||||||
|
value: Math.round(value * 100) / 100,
|
||||||
|
unit,
|
||||||
|
rating,
|
||||||
|
timestamp: now(),
|
||||||
|
tags: {
|
||||||
|
metric: name,
|
||||||
|
rating,
|
||||||
|
...extraTags,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.client.captureEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PerformancePlugin;
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
export type EventLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug';
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id?: string;
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StackFrame {
|
||||||
|
filename: string;
|
||||||
|
function?: string;
|
||||||
|
lineno?: number;
|
||||||
|
colno?: number;
|
||||||
|
in_app?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExceptionInfo {
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
stacktrace?: {
|
||||||
|
frames: StackFrame[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestInfo {
|
||||||
|
url: string;
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
referrer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserContext {
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OSContext {
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceContext {
|
||||||
|
family?: string;
|
||||||
|
model?: string;
|
||||||
|
brand?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Contexts {
|
||||||
|
browser?: BrowserContext;
|
||||||
|
os?: OSContext;
|
||||||
|
device?: DeviceContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Breadcrumb {
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
category?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
level?: EventLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseEvent {
|
||||||
|
type: string;
|
||||||
|
level: EventLevel;
|
||||||
|
timestamp: number;
|
||||||
|
release?: string;
|
||||||
|
environment?: string;
|
||||||
|
user?: UserInfo;
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
breadcrumbs?: Breadcrumb[];
|
||||||
|
request?: RequestInfo;
|
||||||
|
contexts?: Contexts;
|
||||||
|
context_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorEvent extends BaseEvent {
|
||||||
|
type: 'error';
|
||||||
|
message: string;
|
||||||
|
exception?: ExceptionInfo;
|
||||||
|
fingerprint?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerformanceEvent extends BaseEvent {
|
||||||
|
type: 'performance';
|
||||||
|
metric: string;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
rating?: 'good' | 'needs-improvement' | 'poor';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkEvent extends BaseEvent {
|
||||||
|
type: 'network';
|
||||||
|
sub_type: 'fetch' | 'xhr';
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
status_code?: number;
|
||||||
|
duration?: number;
|
||||||
|
request_size?: number;
|
||||||
|
response_size?: number;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BehaviorEvent extends BaseEvent {
|
||||||
|
type: 'behavior';
|
||||||
|
sub_type: 'pv' | 'click' | 'route' | 'scroll' | 'duration';
|
||||||
|
page_url: string;
|
||||||
|
referrer?: string;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CountEvent extends BaseEvent {
|
||||||
|
type: 'count';
|
||||||
|
fingerprint: string;
|
||||||
|
count: number;
|
||||||
|
ts_start: number;
|
||||||
|
ts_end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SentryEvent = ErrorEvent | PerformanceEvent | NetworkEvent | BehaviorEvent | CountEvent;
|
||||||
|
|
||||||
|
export interface DSNInfo {
|
||||||
|
protocol: string;
|
||||||
|
publicKey: string;
|
||||||
|
host: string;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LightConfig {
|
||||||
|
dsn: string;
|
||||||
|
release?: string;
|
||||||
|
environment?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
sampleRate?: number;
|
||||||
|
maxQueueSize?: number;
|
||||||
|
flushInterval?: number;
|
||||||
|
maxRetries?: number;
|
||||||
|
retryDelay?: number;
|
||||||
|
ignoreErrors?: (string | RegExp)[];
|
||||||
|
ignoreUrls?: (string | RegExp)[];
|
||||||
|
includePaths?: (string | RegExp)[];
|
||||||
|
beforeSend?: (event: SentryEvent) => SentryEvent | null;
|
||||||
|
user?: UserInfo;
|
||||||
|
plugins?: (LightPlugin | string)[];
|
||||||
|
[pluginName: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LightPlugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
setup(client: LightClient): void;
|
||||||
|
destroy?(): void;
|
||||||
|
beforeReport?(event: SentryEvent): SentryEvent | null;
|
||||||
|
afterReport?(event: SentryEvent): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LightClient {
|
||||||
|
config: LightConfig;
|
||||||
|
dsn: DSNInfo;
|
||||||
|
on(event: string, handler: (...args: unknown[]) => void): void;
|
||||||
|
off(event: string, handler: (...args: unknown[]) => void): void;
|
||||||
|
emit(event: string, ...args: unknown[]): void;
|
||||||
|
captureException(error: Error | unknown): void;
|
||||||
|
captureMessage(message: string, level?: EventLevel): void;
|
||||||
|
captureEvent(event: Partial<SentryEvent> & { type: string }): void;
|
||||||
|
setUser(user: UserInfo | null): void;
|
||||||
|
setTag(key: string, value: string): void;
|
||||||
|
setTags(tags: Record<string, string>): void;
|
||||||
|
setExtra(key: string, value: unknown): void;
|
||||||
|
addBreadcrumb(breadcrumb: Omit<Breadcrumb, 'timestamp'>): void;
|
||||||
|
flush(): Promise<void>;
|
||||||
|
disable(): void;
|
||||||
|
enable(): void;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { DSNInfo } from '../types';
|
||||||
|
|
||||||
|
export function parseDSN(dsn: string): DSNInfo {
|
||||||
|
try {
|
||||||
|
const match = dsn.match(/^(https?):\/\/([^@]+)@([^/]+)\/(.+)$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Invalid DSN format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, protocol, publicKey, host, projectId] = match;
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol,
|
||||||
|
publicKey,
|
||||||
|
host,
|
||||||
|
projectId,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Failed to parse DSN: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnvelopeUrl(dsn: DSNInfo): string {
|
||||||
|
return `${dsn.protocol}://${dsn.host}/${dsn.projectId}/envelope/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoreUrl(dsn: DSNInfo): string {
|
||||||
|
return `${dsn.protocol}://${dsn.host}/${dsn.projectId}/store/`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
import { md5 } from './hash';
|
||||||
|
|
||||||
|
export interface BrowserInfo {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
major: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OSInfo {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
type: 'desktop' | 'mobile' | 'tablet';
|
||||||
|
vendor: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvInfo {
|
||||||
|
browser: BrowserInfo;
|
||||||
|
os: OSInfo;
|
||||||
|
device: DeviceInfo;
|
||||||
|
url: string;
|
||||||
|
referrer: string;
|
||||||
|
title: string;
|
||||||
|
language: string;
|
||||||
|
user_agent: string;
|
||||||
|
screen_width: number;
|
||||||
|
screen_height: number;
|
||||||
|
viewport_width: number;
|
||||||
|
viewport_height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectBrowser(ua: string): BrowserInfo {
|
||||||
|
const browsers: { name: string; pattern: RegExp; versionIndex?: number }[] = [
|
||||||
|
{ name: 'WeChat', pattern: /MicroMessenger\/([\d.]+)/i },
|
||||||
|
{ name: 'QQ Browser', pattern: /QIBrowser\/([\d.]+)/i },
|
||||||
|
{ name: 'UC Browser', pattern: /UCBrowser\/([\d.]+)/i },
|
||||||
|
{ name: 'Edge', pattern: /Edg\/([\d.]+)/i },
|
||||||
|
{ name: 'Edge', pattern: /Edge\/([\d.]+)/i },
|
||||||
|
{ name: 'Opera', pattern: /OPR\/([\d.]+)/i },
|
||||||
|
{ name: 'Opera', pattern: /Opera\/([\d.]+)/i },
|
||||||
|
{ name: 'Firefox', pattern: /Firefox\/([\d.]+)/i },
|
||||||
|
{ name: 'Safari', pattern: /Version\/([\d.]+).*Safari/i },
|
||||||
|
{ name: 'Chrome', pattern: /Chrome\/([\d.]+)/i },
|
||||||
|
{ name: 'IE', pattern: /MSIE ([\d.]+)/i },
|
||||||
|
{ name: 'IE', pattern: /Trident.*rv:([\d.]+)/i },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const b of browsers) {
|
||||||
|
const match = ua.match(b.pattern);
|
||||||
|
if (match) {
|
||||||
|
const version = match[1] || '';
|
||||||
|
const major = version.split('.')[0] || '';
|
||||||
|
return { name: b.name, version, major };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: 'Unknown', version: '', major: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectOS(ua: string): OSInfo {
|
||||||
|
const oss: { name: string; pattern: RegExp; versionIndex?: number }[] = [
|
||||||
|
{ name: 'Windows 11', pattern: /Windows NT 10\.0.*Win64.*x64/i },
|
||||||
|
{ name: 'Windows 10', pattern: /Windows NT 10\.0/i },
|
||||||
|
{ name: 'Windows 8.1', pattern: /Windows NT 6\.3/i },
|
||||||
|
{ name: 'Windows 8', pattern: /Windows NT 6\.2/i },
|
||||||
|
{ name: 'Windows 7', pattern: /Windows NT 6\.1/i },
|
||||||
|
{ name: 'Windows Vista', pattern: /Windows NT 6\.0/i },
|
||||||
|
{ name: 'Windows XP', pattern: /Windows NT 5\.1/i },
|
||||||
|
{ name: 'macOS', pattern: /Mac OS X ([\d_.]+)/i },
|
||||||
|
{ name: 'iOS', pattern: /OS ([\d_.]+) like Mac OS X/i },
|
||||||
|
{ name: 'Android', pattern: /Android ([\d.]+)/i },
|
||||||
|
{ name: 'Android', pattern: /Android/i },
|
||||||
|
{ name: 'Linux', pattern: /Linux/i },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const o of oss) {
|
||||||
|
const match = ua.match(o.pattern);
|
||||||
|
if (match) {
|
||||||
|
let version = match[1] || '';
|
||||||
|
if (version) {
|
||||||
|
version = version.replace(/_/g, '.');
|
||||||
|
}
|
||||||
|
return { name: o.name, version };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: 'Unknown', version: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectDevice(ua: string): DeviceInfo {
|
||||||
|
const isMobile = /Mobile|Android|iPhone|iPod|BlackBerry|Windows Phone|Opera Mini/i.test(ua);
|
||||||
|
const isTablet = /Tablet|iPad|PlayBook|Kindle|Silk/i.test(ua);
|
||||||
|
|
||||||
|
let type: 'desktop' | 'mobile' | 'tablet' = 'desktop';
|
||||||
|
if (isTablet) {
|
||||||
|
type = 'tablet';
|
||||||
|
} else if (isMobile) {
|
||||||
|
type = 'mobile';
|
||||||
|
}
|
||||||
|
|
||||||
|
let vendor = '';
|
||||||
|
let model = '';
|
||||||
|
|
||||||
|
const iphoneMatch = ua.match(/iPhone/i);
|
||||||
|
const ipadMatch = ua.match(/iPad/i);
|
||||||
|
const samsungMatch = ua.match(/SM-([A-Z0-9]+)/i);
|
||||||
|
const pixelMatch = ua.match(/Pixel ([0-9A-Z]+)/i);
|
||||||
|
const huaweiMatch = ua.match(/HUAWEI ([A-Z0-9]+)/i);
|
||||||
|
|
||||||
|
if (iphoneMatch) {
|
||||||
|
vendor = 'Apple';
|
||||||
|
model = 'iPhone';
|
||||||
|
} else if (ipadMatch) {
|
||||||
|
vendor = 'Apple';
|
||||||
|
model = 'iPad';
|
||||||
|
} else if (samsungMatch) {
|
||||||
|
vendor = 'Samsung';
|
||||||
|
model = samsungMatch[1];
|
||||||
|
} else if (pixelMatch) {
|
||||||
|
vendor = 'Google';
|
||||||
|
model = `Pixel ${pixelMatch[1]}`;
|
||||||
|
} else if (huaweiMatch) {
|
||||||
|
vendor = 'Huawei';
|
||||||
|
model = huaweiMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type, vendor, model };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnvInfo(): EnvInfo {
|
||||||
|
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||||
|
const browser = detectBrowser(ua);
|
||||||
|
const os = detectOS(ua);
|
||||||
|
const device = detectDevice(ua);
|
||||||
|
|
||||||
|
return {
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
device,
|
||||||
|
url: typeof location !== 'undefined' ? location.href : '',
|
||||||
|
referrer: typeof document !== 'undefined' ? document.referrer : '',
|
||||||
|
title: typeof document !== 'undefined' ? document.title : '',
|
||||||
|
language: typeof navigator !== 'undefined' ? navigator.language : '',
|
||||||
|
user_agent: ua,
|
||||||
|
screen_width: typeof screen !== 'undefined' ? screen.width : 0,
|
||||||
|
screen_height: typeof screen !== 'undefined' ? screen.height : 0,
|
||||||
|
viewport_width: typeof window !== 'undefined' ? window.innerWidth : 0,
|
||||||
|
viewport_height: typeof window !== 'undefined' ? window.innerHeight : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPageUrl(): string {
|
||||||
|
return typeof location !== 'undefined' ? location.href : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReferrer(): string {
|
||||||
|
return typeof document !== 'undefined' ? document.referrer : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeUrl(url: string): string {
|
||||||
|
if (!url) return url;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const sensitiveParams = [
|
||||||
|
'password', 'passwd', 'pwd',
|
||||||
|
'token', 'access_token', 'refresh_token',
|
||||||
|
'api_key', 'apikey', 'key',
|
||||||
|
'secret', 'private_key',
|
||||||
|
'code', 'auth',
|
||||||
|
'id_token', 'idToken',
|
||||||
|
'session', 'session_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
const params = urlObj.searchParams;
|
||||||
|
let modified = false;
|
||||||
|
|
||||||
|
for (const key of Array.from(params.keys())) {
|
||||||
|
const lowerKey = key.toLowerCase().replace(/[-_]/g, '_');
|
||||||
|
if (sensitiveParams.some(s => lowerKey.includes(s))) {
|
||||||
|
params.set(key, '[Filtered]');
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified) {
|
||||||
|
return urlObj.toString();
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldIgnoreUrl(url: string, ignoreUrls: (string | RegExp)[] = []): boolean {
|
||||||
|
if (!ignoreUrls || ignoreUrls.length === 0) return false;
|
||||||
|
|
||||||
|
return ignoreUrls.some(pattern => {
|
||||||
|
if (typeof pattern === 'string') {
|
||||||
|
if (pattern.includes('*')) {
|
||||||
|
// 移除开头的 ^ 和结尾的 $,允许匹配 URL 的任意部分
|
||||||
|
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
||||||
|
const regex = new RegExp(escaped);
|
||||||
|
return regex.test(url);
|
||||||
|
}
|
||||||
|
return url.includes(pattern);
|
||||||
|
}
|
||||||
|
return pattern.test(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const BROWSER_CODE_MAP: Record<string, string> = {
|
||||||
|
'Chrome': 'c',
|
||||||
|
'Safari': 's',
|
||||||
|
'Firefox': 'f',
|
||||||
|
'Edge': 'e',
|
||||||
|
'Opera': 'o',
|
||||||
|
'IE': 'i',
|
||||||
|
'WeChat': 'w',
|
||||||
|
'QQ Browser': 'q',
|
||||||
|
'UC Browser': 'u',
|
||||||
|
'Mobile Chrome': 'cm',
|
||||||
|
'Mobile Safari': 'sm',
|
||||||
|
};
|
||||||
|
|
||||||
|
const OS_CODE_MAP: Record<string, string> = {
|
||||||
|
'Windows 11': 'w11',
|
||||||
|
'Windows 10': 'w10',
|
||||||
|
'Windows 8.1': 'w81',
|
||||||
|
'Windows 8': 'w8',
|
||||||
|
'Windows 7': 'w7',
|
||||||
|
'Windows Vista': 'wv',
|
||||||
|
'Windows XP': 'wxp',
|
||||||
|
'macOS': 'm',
|
||||||
|
'iOS': 'i',
|
||||||
|
'Android': 'a',
|
||||||
|
'Linux': 'l',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getContexts() {
|
||||||
|
const env = getEnvInfo();
|
||||||
|
const browserCode = BROWSER_CODE_MAP[env.browser.name] || env.browser.name.toLowerCase();
|
||||||
|
const osCode = OS_CODE_MAP[env.os.name] || env.os.name.toLowerCase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
browser: {
|
||||||
|
name: browserCode,
|
||||||
|
version: env.browser.major,
|
||||||
|
},
|
||||||
|
os: {
|
||||||
|
name: osCode,
|
||||||
|
version: env.os.version.split('.')[0],
|
||||||
|
},
|
||||||
|
device: {
|
||||||
|
type: env.device.type,
|
||||||
|
brand: env.device.vendor || undefined,
|
||||||
|
model: env.device.model || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContextId(): string {
|
||||||
|
const env = getEnvInfo();
|
||||||
|
const key = `${env.browser.name}|${env.browser.major}|${env.os.name}|${env.os.version.split('.')[0]}|${env.device.type}|${env.device.vendor}|${env.device.model}|${env.language}`;
|
||||||
|
return md5(key).substring(0, 16);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
/**
|
||||||
|
* 字段短编码映射
|
||||||
|
*
|
||||||
|
* 将常用字段名编码为 2-3 个字符的短码,减少上报体积
|
||||||
|
*
|
||||||
|
* 编码前:{ "type": "error", "level": "error", "message": "..." }
|
||||||
|
* 编码后:{ "t": "e", "l": "e", "m": "..." }
|
||||||
|
*
|
||||||
|
* 注意:只对最外层高频字段编码,不对嵌套对象深度编码
|
||||||
|
* 避免复杂嵌套带来的解析问题和兼容性风险
|
||||||
|
*/
|
||||||
|
|
||||||
|
// SDK 端:字段名编码映射(只包含最外层高频字段)
|
||||||
|
export const FIELD_ENCODE_MAP: Record<string, string> = {
|
||||||
|
// 基础字段
|
||||||
|
event_id: 'eid',
|
||||||
|
timestamp: 'ts',
|
||||||
|
start_timestamp: 'sts',
|
||||||
|
type: 't',
|
||||||
|
level: 'l',
|
||||||
|
message: 'm',
|
||||||
|
platform: 'p',
|
||||||
|
release: 'r',
|
||||||
|
environment: 'e',
|
||||||
|
fingerprint: 'fp',
|
||||||
|
context_id: 'cid',
|
||||||
|
|
||||||
|
// 用户
|
||||||
|
user: 'u',
|
||||||
|
|
||||||
|
// 标签
|
||||||
|
tags: 'tg',
|
||||||
|
|
||||||
|
// 异常
|
||||||
|
exception: 'ex',
|
||||||
|
|
||||||
|
// 请求
|
||||||
|
request: 'req',
|
||||||
|
|
||||||
|
// 上下文
|
||||||
|
contexts: 'ctx',
|
||||||
|
|
||||||
|
// 面包屑
|
||||||
|
breadcrumbs: 'bc',
|
||||||
|
|
||||||
|
// Transaction
|
||||||
|
transaction: 'tx',
|
||||||
|
duration: 'd',
|
||||||
|
spans: 'sp',
|
||||||
|
|
||||||
|
// 额外信息
|
||||||
|
extra: 'xt',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 服务端:字段名解码映射(反向)
|
||||||
|
export const FIELD_DECODE_MAP: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(FIELD_ENCODE_MAP).map(([k, v]) => [v, k])
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编码事件字段名(SDK 端使用)
|
||||||
|
* 只对最外层字段编码,避免深度编码的复杂性
|
||||||
|
*/
|
||||||
|
export function encodeFields(obj: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
if (!obj || typeof obj !== 'object') return obj;
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
const shortKey = FIELD_ENCODE_MAP[key] || key;
|
||||||
|
result[shortKey] = value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解码事件字段名(服务端使用)
|
||||||
|
* 只对最外层字段解码
|
||||||
|
*/
|
||||||
|
export function decodeFields(obj: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
if (!obj || typeof obj !== 'object') return obj;
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
const fullKey = FIELD_DECODE_MAP[key] || key;
|
||||||
|
result[fullKey] = value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
export function hashString(str: string): number {
|
||||||
|
let hash = 5381;
|
||||||
|
let i = str.length;
|
||||||
|
while (i) {
|
||||||
|
hash = (hash * 33) ^ str.charCodeAt(--i);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function md5(str: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const char = str.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash;
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(16).padStart(8, '0') +
|
||||||
|
Math.abs(hash * 31).toString(16).padStart(8, '0') +
|
||||||
|
Math.abs(hash * 17).toString(16).padStart(8, '0') +
|
||||||
|
Math.abs(hash * 7).toString(16).padStart(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFingerprint(type: string, message: string, frames: { filename: string; lineno?: number }[] = []): string {
|
||||||
|
const keyFrames = frames.slice(0, 3).map(f => `${f.filename}:${f.lineno || 0}`).join('|');
|
||||||
|
const normalizedMessage = message.replace(/['"]?\d+['"]?/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
return md5(`${type}:${normalizedMessage}:${keyFrames}`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
export function generateEventId(): string {
|
||||||
|
return 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'.replace(/[x]/g, () => {
|
||||||
|
return ((Math.random() * 16) | 0).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function now(): number {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uuid(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID().replace(/-/g, '');
|
||||||
|
}
|
||||||
|
return generateEventId();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFunction(fn: unknown): fn is (...args: unknown[]) => unknown {
|
||||||
|
return typeof fn === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isString(value: unknown): value is string {
|
||||||
|
return typeof value === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeGet<T>(fn: () => T, defaultValue: T): T {
|
||||||
|
try {
|
||||||
|
const result = fn();
|
||||||
|
return result === undefined ? defaultValue : result;
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(str: string, maxLen: number): string {
|
||||||
|
if (str.length <= maxLen) return str;
|
||||||
|
return str.slice(0, maxLen) + '...';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ConfigManager } from '../src/core/ConfigManager';
|
||||||
|
import type { LightConfig, SentryEvent, ErrorEvent } from '../src/types';
|
||||||
|
|
||||||
|
// Mock the env module
|
||||||
|
vi.mock('../src/utils/env', () => ({
|
||||||
|
getContexts: vi.fn(() => ({
|
||||||
|
browser: { name: 'c', version: '120' },
|
||||||
|
os: { name: 'm', version: '14' },
|
||||||
|
device: { type: 'desktop' },
|
||||||
|
})),
|
||||||
|
getContextId: vi.fn(() => 'test_context_id'),
|
||||||
|
getPageUrl: vi.fn(() => 'https://example.com/page'),
|
||||||
|
getReferrer: vi.fn(() => 'https://google.com'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ConfigManager', () => {
|
||||||
|
let configManager: ConfigManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should create ConfigManager with valid config', () => {
|
||||||
|
const config: LightConfig = {
|
||||||
|
dsn: 'https://proj_abc123@log.example.com/1001',
|
||||||
|
release: '1.0.0',
|
||||||
|
environment: 'production',
|
||||||
|
};
|
||||||
|
configManager = new ConfigManager(config);
|
||||||
|
expect(configManager).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default values for optional config', () => {
|
||||||
|
const config: LightConfig = {
|
||||||
|
dsn: 'https://proj_abc123@log.example.com/1001',
|
||||||
|
};
|
||||||
|
configManager = new ConfigManager(config);
|
||||||
|
expect(configManager.get('enabled')).toBe(true);
|
||||||
|
expect(configManager.get('sampleRate')).toBe(1);
|
||||||
|
expect(configManager.get('environment')).toBe('production');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get/set methods', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
configManager = new ConfigManager({
|
||||||
|
dsn: 'https://proj_abc123@log.example.com/1001',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get configured value', () => {
|
||||||
|
expect(configManager.get('environment')).toBe('production');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set and get value', () => {
|
||||||
|
configManager.set('environment', 'development');
|
||||||
|
expect(configManager.get('environment')).toBe('development');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set user info', () => {
|
||||||
|
configManager.setUser({ id: '123', username: 'test' });
|
||||||
|
const config = configManager.getAll();
|
||||||
|
expect(config.user?.id).toBe('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set single tag', () => {
|
||||||
|
configManager.setTag('page', 'home');
|
||||||
|
const config = configManager.getAll();
|
||||||
|
expect(config.tags?.page).toBe('home');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set multiple tags', () => {
|
||||||
|
configManager.setTags({ page: 'home', version: '2.0' });
|
||||||
|
const config = configManager.getAll();
|
||||||
|
expect(config.tags?.page).toBe('home');
|
||||||
|
expect(config.tags?.version).toBe('2.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set extra data', () => {
|
||||||
|
configManager.setExtra('order_id', '12345');
|
||||||
|
const config = configManager.getAll();
|
||||||
|
expect(config.extra?.order_id).toBe('12345');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldSample', () => {
|
||||||
|
it('should return true when sampleRate is 1', () => {
|
||||||
|
configManager = new ConfigManager({
|
||||||
|
dsn: 'https://proj_abc123@log.example.com/1001',
|
||||||
|
sampleRate: 1,
|
||||||
|
});
|
||||||
|
expect(configManager.shouldSample()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when sampleRate is 0', () => {
|
||||||
|
configManager = new ConfigManager({
|
||||||
|
dsn: 'https://proj_abc123@log.example.com/1001',
|
||||||
|
sampleRate: 0,
|
||||||
|
});
|
||||||
|
expect(configManager.shouldSample()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isIgnoredError', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
configManager = new ConfigManager({
|
||||||
|
dsn: 'https://proj_abc123@log.example.com/1001',
|
||||||
|
ignoreErrors: [/^Script error/i, 'ResizeObserver'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore matching pattern', () => {
|
||||||
|
expect(configManager.isIgnoredError('Script error')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore matching regex', () => {
|
||||||
|
expect(configManager.isIgnoredError('Script error: something')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not ignore non-matching message', () => {
|
||||||
|
expect(configManager.isIgnoredError('TypeError: something')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string pattern', () => {
|
||||||
|
expect(configManager.isIgnoredError('Some ResizeObserver issue')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyToEvent', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
configManager = new ConfigManager({
|
||||||
|
dsn: 'https://proj_abc123@log.example.com/1001',
|
||||||
|
release: '1.0.0',
|
||||||
|
environment: 'production',
|
||||||
|
user: { id: '123' },
|
||||||
|
tags: { page: 'home' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply release to event', () => {
|
||||||
|
const event: SentryEvent = {
|
||||||
|
type: 'error',
|
||||||
|
level: 'error',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as ErrorEvent;
|
||||||
|
const result = configManager.applyToEvent(event);
|
||||||
|
expect(result.release).toBe('1.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply environment to event', () => {
|
||||||
|
const event: SentryEvent = {
|
||||||
|
type: 'error',
|
||||||
|
level: 'error',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as ErrorEvent;
|
||||||
|
const result = configManager.applyToEvent(event);
|
||||||
|
expect(result.environment).toBe('production');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply user to event', () => {
|
||||||
|
const event: SentryEvent = {
|
||||||
|
type: 'error',
|
||||||
|
level: 'error',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as ErrorEvent;
|
||||||
|
const result = configManager.applyToEvent(event);
|
||||||
|
expect(result.user?.id).toBe('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply and merge tags', () => {
|
||||||
|
const event: SentryEvent = {
|
||||||
|
type: 'error',
|
||||||
|
level: 'error',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
tags: { custom: 'value' },
|
||||||
|
} as ErrorEvent;
|
||||||
|
const result = configManager.applyToEvent(event);
|
||||||
|
expect(result.tags?.page).toBe('home');
|
||||||
|
expect(result.tags?.custom).toBe('value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add context_id to event', () => {
|
||||||
|
const event: SentryEvent = {
|
||||||
|
type: 'error',
|
||||||
|
level: 'error',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as ErrorEvent;
|
||||||
|
const result = configManager.applyToEvent(event);
|
||||||
|
expect(result.context_id).toBe('test_context_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add contexts on first event', () => {
|
||||||
|
const event: SentryEvent = {
|
||||||
|
type: 'error',
|
||||||
|
level: 'error',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as ErrorEvent;
|
||||||
|
const result = configManager.applyToEvent(event);
|
||||||
|
expect(result.contexts).toBeDefined();
|
||||||
|
expect(result.contexts?.browser).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add request info to event', () => {
|
||||||
|
const event: SentryEvent = {
|
||||||
|
type: 'error',
|
||||||
|
level: 'error',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as ErrorEvent;
|
||||||
|
const result = configManager.applyToEvent(event);
|
||||||
|
expect(result.request?.url).toBe('https://example.com/page');
|
||||||
|
expect(result.request?.referrer).toBe('https://google.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not overwrite existing request.url', () => {
|
||||||
|
const event: SentryEvent = {
|
||||||
|
type: 'error',
|
||||||
|
level: 'error',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
request: { url: 'https://custom.com/api' },
|
||||||
|
} as ErrorEvent;
|
||||||
|
const result = configManager.applyToEvent(event);
|
||||||
|
expect(result.request?.url).toBe('https://custom.com/api');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markContextReported', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
configManager = new ConfigManager({
|
||||||
|
dsn: 'https://proj_abc123@log.example.com/1001',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add contexts on first event', () => {
|
||||||
|
configManager.markContextReported();
|
||||||
|
|
||||||
|
const event: SentryEvent = {
|
||||||
|
type: 'error',
|
||||||
|
level: 'error',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as ErrorEvent;
|
||||||
|
const result = configManager.applyToEvent(event);
|
||||||
|
expect(result.contexts).toBeUndefined();
|
||||||
|
expect(result.context_id).toBe('test_context_id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseDSN, getEnvelopeUrl } from '../src/utils/dsn';
|
||||||
|
|
||||||
|
describe('dsn utils', () => {
|
||||||
|
describe('parseDSN', () => {
|
||||||
|
it('should parse valid DSN correctly', () => {
|
||||||
|
const dsn = parseDSN('https://proj_abc123@log.example.com/1001');
|
||||||
|
expect(dsn.protocol).toBe('https');
|
||||||
|
expect(dsn.publicKey).toBe('proj_abc123');
|
||||||
|
expect(dsn.host).toBe('log.example.com');
|
||||||
|
expect(dsn.projectId).toBe('1001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse DSN with http protocol', () => {
|
||||||
|
const dsn = parseDSN('http://proj_abc123@log.example.com/1001');
|
||||||
|
expect(dsn.protocol).toBe('http');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse DSN with numeric public key', () => {
|
||||||
|
const dsn = parseDSN('https://123456@log.example.com/1001');
|
||||||
|
expect(dsn.publicKey).toBe('123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid DSN', () => {
|
||||||
|
expect(() => parseDSN('invalid-dsn')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for DSN without project ID', () => {
|
||||||
|
expect(() => parseDSN('https://proj_abc123@log.example.com')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEnvelopeUrl', () => {
|
||||||
|
it('should return correct envelope URL', () => {
|
||||||
|
const dsn = {
|
||||||
|
protocol: 'https',
|
||||||
|
publicKey: 'proj_abc123',
|
||||||
|
host: 'log.example.com',
|
||||||
|
projectId: '1001',
|
||||||
|
};
|
||||||
|
const url = getEnvelopeUrl(dsn);
|
||||||
|
expect(url).toContain('https://log.example.com/api/1001/envelope/');
|
||||||
|
expect(url).toContain('sentry_key=proj_abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use http for non-https protocol', () => {
|
||||||
|
const dsn = {
|
||||||
|
protocol: 'http',
|
||||||
|
publicKey: 'proj_abc123',
|
||||||
|
host: 'log.example.com',
|
||||||
|
projectId: '1001',
|
||||||
|
};
|
||||||
|
const url = getEnvelopeUrl(dsn);
|
||||||
|
expect(url).toContain('http://log.example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
getEnvInfo,
|
||||||
|
getContexts,
|
||||||
|
getContextId,
|
||||||
|
getPageUrl,
|
||||||
|
getReferrer,
|
||||||
|
sanitizeUrl,
|
||||||
|
shouldIgnoreUrl,
|
||||||
|
} from '../src/utils/env';
|
||||||
|
|
||||||
|
describe('env utils', () => {
|
||||||
|
describe('getEnvInfo', () => {
|
||||||
|
it('should return env info object', () => {
|
||||||
|
const env = getEnvInfo();
|
||||||
|
expect(env).toBeDefined();
|
||||||
|
expect(env).toHaveProperty('browser');
|
||||||
|
expect(env).toHaveProperty('os');
|
||||||
|
expect(env).toHaveProperty('device');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid browser info', () => {
|
||||||
|
const env = getEnvInfo();
|
||||||
|
expect(env.browser.name).toBeDefined();
|
||||||
|
expect(env.browser.version).toBeDefined();
|
||||||
|
expect(env.browser.major).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid os info', () => {
|
||||||
|
const env = getEnvInfo();
|
||||||
|
expect(env.os.name).toBeDefined();
|
||||||
|
expect(env.os.version).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have valid device info', () => {
|
||||||
|
const env = getEnvInfo();
|
||||||
|
expect(['desktop', 'mobile', 'tablet']).toContain(env.device.type);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getContexts', () => {
|
||||||
|
it('should return contexts with encoded values', () => {
|
||||||
|
const contexts = getContexts();
|
||||||
|
expect(contexts).toBeDefined();
|
||||||
|
expect(contexts.browser).toBeDefined();
|
||||||
|
expect(contexts.os).toBeDefined();
|
||||||
|
expect(contexts.device).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use short codes for browser', () => {
|
||||||
|
const contexts = getContexts();
|
||||||
|
expect(contexts.browser.name.length).toBeLessThanOrEqual(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use short codes for os', () => {
|
||||||
|
const contexts = getContexts();
|
||||||
|
expect(contexts.os.name.length).toBeLessThanOrEqual(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getContextId', () => {
|
||||||
|
it('should return a 16 character string', () => {
|
||||||
|
const contextId = getContextId();
|
||||||
|
expect(contextId).toBeDefined();
|
||||||
|
expect(contextId.length).toBe(16);
|
||||||
|
expect(contextId).toMatch(/^[a-f0-9]+$/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return consistent id for same environment', () => {
|
||||||
|
const id1 = getContextId();
|
||||||
|
const id2 = getContextId();
|
||||||
|
expect(id1).toBe(id2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPageUrl', () => {
|
||||||
|
it('should return current page URL or empty string', () => {
|
||||||
|
const url = getPageUrl();
|
||||||
|
expect(typeof url).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getReferrer', () => {
|
||||||
|
it('should return referrer or empty string', () => {
|
||||||
|
const referrer = getReferrer();
|
||||||
|
expect(typeof referrer).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeUrl', () => {
|
||||||
|
it('should filter sensitive parameters', () => {
|
||||||
|
const url = 'https://example.com/api?user=123&password=secret&token=abc';
|
||||||
|
const sanitized = sanitizeUrl(url);
|
||||||
|
expect(sanitized).toContain('password');
|
||||||
|
expect(sanitized).toContain('token');
|
||||||
|
// URL encoded [Filtered] = %5BFiltered%5D
|
||||||
|
expect(sanitized).toContain('%5BFiltered%5D');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle URLs without sensitive params', () => {
|
||||||
|
const url = 'https://example.com/api?page=1&limit=10';
|
||||||
|
const sanitized = sanitizeUrl(url);
|
||||||
|
expect(sanitized).toBe(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid URLs', () => {
|
||||||
|
const sanitized = sanitizeUrl('not-a-url');
|
||||||
|
expect(sanitized).toBe('not-a-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter api_key parameter', () => {
|
||||||
|
const url = 'https://api.com/endpoint?api_key=secret123';
|
||||||
|
const sanitized = sanitizeUrl(url);
|
||||||
|
expect(sanitized).toContain('api_key');
|
||||||
|
expect(sanitized).toContain('%5BFiltered%5D');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter authorization headers in URL', () => {
|
||||||
|
const url = 'https://example.com/api?code=auth123';
|
||||||
|
const sanitized = sanitizeUrl(url);
|
||||||
|
expect(sanitized).toContain('code');
|
||||||
|
expect(sanitized).toContain('%5BFiltered%5D');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldIgnoreUrl', () => {
|
||||||
|
it('should return false for empty patterns', () => {
|
||||||
|
const result = shouldIgnoreUrl('https://example.com/api', []);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match string pattern', () => {
|
||||||
|
const result = shouldIgnoreUrl('https://example.com/api/health', ['/health']);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match non-matching pattern', () => {
|
||||||
|
const result = shouldIgnoreUrl('https://example.com/api/users', ['/health']);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match wildcard pattern', () => {
|
||||||
|
const result = shouldIgnoreUrl('https://example.com/api/v1/users', ['/api/v1/*']);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match wildcard when no wildcard in URL', () => {
|
||||||
|
const result = shouldIgnoreUrl('https://example.com/api/v2/users', ['/api/v1/*']);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match regex pattern', () => {
|
||||||
|
const result = shouldIgnoreUrl('https://example.com/api/users/123', [/api\/users\/\d+/]);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { hashString, md5, computeFingerprint } from '../src/utils/hash';
|
||||||
|
|
||||||
|
describe('hash utils', () => {
|
||||||
|
describe('hashString', () => {
|
||||||
|
it('should return a positive number', () => {
|
||||||
|
const result = hashString('test');
|
||||||
|
expect(result).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return consistent results for same input', () => {
|
||||||
|
const result1 = hashString('test');
|
||||||
|
const result2 = hashString('test');
|
||||||
|
expect(result1).toBe(result2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return different results for different inputs', () => {
|
||||||
|
const result1 = hashString('test1');
|
||||||
|
const result2 = hashString('test2');
|
||||||
|
expect(result1).not.toBe(result2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('md5', () => {
|
||||||
|
it('should return a hex string', () => {
|
||||||
|
const result = md5('test');
|
||||||
|
expect(result).toMatch(/^[a-f0-9]+$/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return consistent results for same input', () => {
|
||||||
|
const result1 = md5('test');
|
||||||
|
const result2 = md5('test');
|
||||||
|
expect(result1).toBe(result2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return different results for different inputs', () => {
|
||||||
|
const result1 = md5('test1');
|
||||||
|
const result2 = md5('test2');
|
||||||
|
expect(result1).not.toBe(result2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
const result = md5('');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeFingerprint', () => {
|
||||||
|
it('should generate fingerprint for error', () => {
|
||||||
|
const frames = [
|
||||||
|
{ filename: 'app.js', lineno: 10 },
|
||||||
|
{ filename: 'main.js', lineno: 5 },
|
||||||
|
];
|
||||||
|
const result = computeFingerprint('TypeError', 'test error', frames);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize message by removing numbers', () => {
|
||||||
|
const frames: { filename: string; lineno?: number }[] = [];
|
||||||
|
const result1 = computeFingerprint('Error', 'User 123 not found', frames);
|
||||||
|
const result2 = computeFingerprint('Error', 'User 456 not found', frames);
|
||||||
|
expect(result1).toBe(result2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only use first 3 frames', () => {
|
||||||
|
const frames = [
|
||||||
|
{ filename: 'a.js', lineno: 1 },
|
||||||
|
{ filename: 'b.js', lineno: 2 },
|
||||||
|
{ filename: 'c.js', lineno: 3 },
|
||||||
|
{ filename: 'd.js', lineno: 4 },
|
||||||
|
{ filename: 'e.js', lineno: 5 },
|
||||||
|
];
|
||||||
|
const result = computeFingerprint('Error', 'message', frames);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty frames', () => {
|
||||||
|
const result = computeFingerprint('Error', 'message', []);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2018",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2018", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationDir": "./dist",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/**/*.d.ts'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue