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