feat: light sentry sdk

This commit is contained in:
weidingjian 2026-06-24 21:12:04 +08:00
commit c8ab7ca010
49 changed files with 7739 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*

38
dist/core/Client.d.ts vendored Normal file
View File

@ -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;

33
dist/core/ConfigManager.d.ts vendored Normal file
View File

@ -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;
}

10
dist/core/EventBus.d.ts vendored Normal file
View File

@ -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 {};

29
dist/core/EventQueue.d.ts vendored Normal file
View File

@ -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;
}

13
dist/core/PluginManager.d.ts vendored Normal file
View File

@ -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;
}

43
dist/core/Reporter.d.ts vendored Normal file
View File

@ -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;
}

4
dist/index.cjs.js vendored Normal file

File diff suppressed because one or more lines are too long

40
dist/index.d.ts vendored Normal file
View File

@ -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;

4
dist/index.esm.js vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/index.iife.js vendored Normal file

File diff suppressed because one or more lines are too long

25
dist/plugins/BehaviorPlugin.d.ts vendored Normal file
View File

@ -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;

18
dist/plugins/ErrorPlugin.d.ts vendored Normal file
View File

@ -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;

19
dist/plugins/NetworkPlugin.d.ts vendored Normal file
View File

@ -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;

24
dist/plugins/OfflinePlugin.d.ts vendored Normal file
View File

@ -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;

24
dist/plugins/PerformancePlugin.d.ts vendored Normal file
View File

@ -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;

161
dist/types/index.d.ts vendored Normal file
View File

@ -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;
}

4
dist/utils/dsn.d.ts vendored Normal file
View File

@ -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;

49
dist/utils/env.d.ts vendored Normal file
View File

@ -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;

23
dist/utils/field-encoder.d.ts vendored Normal file
View File

@ -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>;

6
dist/utils/hash.d.ts vendored Normal file
View File

@ -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;

8
dist/utils/helper.d.ts vendored Normal file
View File

@ -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;

3065
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@ -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"
}
}

125
scripts/build.js Normal file
View File

@ -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);
});
}

301
src/core/Client.ts Normal file
View File

@ -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;

183
src/core/ConfigManager.ts Normal file
View File

@ -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;
}
}
}

47
src/core/EventBus.ts Normal file
View File

@ -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();
}
}

247
src/core/EventQueue.ts Normal file
View File

@ -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 = {};
}
}

87
src/core/PluginManager.ts Normal file
View File

@ -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();
}
}

336
src/core/Reporter.ts Normal file
View File

@ -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_timestamptransaction 事件)
if (event.start_timestamp) {
const startTs = new Date(event.start_timestamp as string | number).getTime();
const startDelta = startTs - baseTimestamp;
delete event.start_timestamp;
event._dsts = startDelta; // delta start timestamp
}
result.push(event as SentryEvent);
}
}
return result;
}
/**
*
*/
private extractSharedMeta(events: SentryEvent[]): Record<string, unknown> {
if (events.length === 0) return {};
const firstEvent = events[0];
const meta: Record<string, unknown> = {};
// 提取 release如果所有事件都相同
if (firstEvent.release) {
const allSameRelease = events.every(e => e.release === firstEvent.release);
if (allSameRelease) {
meta.release = firstEvent.release;
}
}
// 提取 environment如果所有事件都相同
if (firstEvent.environment) {
const allSameEnv = events.every(e => e.environment === firstEvent.environment);
if (allSameEnv) {
meta.environment = firstEvent.environment;
}
}
// 提取 user如果所有事件都相同
if (firstEvent.user) {
const allSameUser = events.every(e =>
JSON.stringify(e.user) === JSON.stringify(firstEvent.user)
);
if (allSameUser) {
meta.user = firstEvent.user;
}
}
// 提取 env编码后的环境信息
const firstEnv = (firstEvent as Record<string, unknown>).env;
if (firstEnv) {
meta.env = firstEnv;
}
return meta;
}
/**
*
*/
private stripSharedFields(event: SentryEvent, meta: Record<string, unknown>): SentryEvent {
const stripped = { ...event };
// 移除已共享的字段
if (meta.release && stripped.release === meta.release) {
delete stripped.release;
}
if (meta.environment && stripped.environment === meta.environment) {
delete stripped.environment;
}
if (meta.user && JSON.stringify(stripped.user) === JSON.stringify(meta.user)) {
delete stripped.user;
}
if (meta.env) {
delete (stripped as Record<string, unknown>).env;
}
return stripped;
}
private getEnvelopeType(event: SentryEvent): string {
switch (event.type) {
case 'error':
return 'event';
case 'performance':
return 'transaction';
default:
return event.type;
}
}
private generateEventId(): string {
return 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'.replace(/[x]/g, () => {
return ((Math.random() * 16) | 0).toString(16);
});
}
private async send(body: string, isSync: boolean = false): Promise<void> {
const url = getEnvelopeUrl(this.dsn);
if (navigator.sendBeacon) {
try {
const blob = new Blob([body], { type: 'application/x-sentry-envelope' });
const success = navigator.sendBeacon(url, blob);
if (success) return;
} catch {
// sendBeacon failed, fall through
}
}
if (typeof fetch === 'function') {
try {
const response = await fetch(url, {
method: 'POST',
body,
headers: {
'Content-Type': 'application/x-sentry-envelope',
},
keepalive: true,
});
if (response.ok) return;
const err = new Error(`HTTP ${response.status}`) as Error & { status: number };
err.status = response.status;
throw err;
} catch (e) {
throw e;
}
}
if (typeof XMLHttpRequest !== 'undefined') {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, !isSync);
xhr.setRequestHeader('Content-Type', 'application/x-sentry-envelope');
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
const err = new Error(`HTTP ${xhr.status}`) as Error & { status: number };
err.status = xhr.status;
reject(err);
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(body);
});
}
throw new Error('No transport available');
}
private sendSync(body: string): boolean {
const url = getEnvelopeUrl(this.dsn);
if (navigator.sendBeacon) {
try {
const blob = new Blob([body], { type: 'application/x-sentry-envelope' });
return navigator.sendBeacon(url, blob);
} catch {
return false;
}
}
if (typeof XMLHttpRequest !== 'undefined') {
try {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, false);
xhr.setRequestHeader('Content-Type', 'application/x-sentry-envelope');
xhr.send(body);
return xhr.status >= 200 && xhr.status < 300;
} catch {
return false;
}
}
return false;
}
private sendViaImage(body: string): boolean {
try {
const url = getEnvelopeUrl(this.dsn);
const img = new Image();
const encoded = encodeURIComponent(btoa(body));
img.src = url + '&sentry_data=' + encoded.substring(0, 2000);
return true;
} catch {
return false;
}
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

148
src/index.ts Normal file
View File

@ -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,
};

View File

@ -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;

262
src/plugins/ErrorPlugin.ts Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

179
src/types/index.ts Normal file
View File

@ -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;
}

29
src/utils/dsn.ts Normal file
View File

@ -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/`;
}

269
src/utils/env.ts Normal file
View File

@ -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);
}

View File

@ -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;
}

27
src/utils/hash.ts Normal file
View File

@ -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}`);
}

42
src/utils/helper.ts Normal file
View File

@ -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) + '...';
}

248
tests/ConfigManager.test.ts Normal file
View File

@ -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');
});
});
});

57
tests/dsn.test.ts Normal file
View File

@ -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');
});
});
});

157
tests/env.test.ts Normal file
View File

@ -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);
});
});
});

84
tests/hash.test.ts Normal file
View File

@ -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();
});
});
});

23
tsconfig.json Normal file
View File

@ -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"]
}

15
vitest.config.ts Normal file
View File

@ -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'],
},
},
});