feat: 新增功能
This commit is contained in:
parent
c8ab7ca010
commit
fc95159945
|
|
@ -1,6 +1,5 @@
|
|||
import type { LightConfig, LightClient, DSNInfo, SentryEvent, EventLevel, UserInfo, Breadcrumb } from '../types';
|
||||
import type { LightConfig, LightClient, LightPlugin, DSNInfo, SentryEvent, EventLevel, UserInfo, Breadcrumb } from '../types';
|
||||
declare class Client implements LightClient {
|
||||
config: LightConfig;
|
||||
dsn: DSNInfo;
|
||||
private configManager;
|
||||
private eventBus;
|
||||
|
|
@ -8,8 +7,9 @@ declare class Client implements LightClient {
|
|||
private reporter;
|
||||
private pluginManager;
|
||||
private breadcrumbs;
|
||||
private maxBreadcrumbs;
|
||||
private enabled;
|
||||
private get maxBreadcrumbs();
|
||||
get config(): LightConfig;
|
||||
constructor(config: LightConfig);
|
||||
init(): void;
|
||||
private flushEvents;
|
||||
|
|
@ -23,7 +23,6 @@ declare class Client implements LightClient {
|
|||
type: string;
|
||||
}): void;
|
||||
private buildErrorEvent;
|
||||
private parseStackTrace;
|
||||
setUser(user: UserInfo | null): void;
|
||||
setTag(key: string, value: string): void;
|
||||
setTags(tags: Record<string, string>): void;
|
||||
|
|
@ -32,7 +31,7 @@ declare class Client implements LightClient {
|
|||
flush(): Promise<void>;
|
||||
disable(): void;
|
||||
enable(): void;
|
||||
use(plugin: unknown): void;
|
||||
use(plugin: LightPlugin): void;
|
||||
destroy(): void;
|
||||
}
|
||||
export default Client;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export declare class ConfigManager {
|
|||
setTags(tags: Record<string, string>): void;
|
||||
setTag(key: string, value: string): void;
|
||||
setExtra(key: string, value: unknown): void;
|
||||
shouldSample(): boolean;
|
||||
isIgnoredError(message: string): boolean;
|
||||
/**
|
||||
* 获取编码后的环境信息(延迟初始化,只在首次上报时计算)
|
||||
|
|
@ -28,6 +27,7 @@ export declare class ConfigManager {
|
|||
applyToEvent(event: SentryEvent, includeFullContext?: boolean): SentryEvent;
|
||||
/**
|
||||
* 应用上下文分层策略:根据事件级别裁剪堆栈和 breadcrumbs
|
||||
* 注意:此方法不修改原始 event 对象,返回裁剪后的副本
|
||||
*/
|
||||
private applyContextLevel;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ export declare class EventQueue {
|
|||
private flushCallback;
|
||||
private syncFlushCallback;
|
||||
private lastFlushTime;
|
||||
private isFlushing;
|
||||
private onVisibilityChange?;
|
||||
private onBeforeUnload?;
|
||||
private onPagehide?;
|
||||
private dedupeMap;
|
||||
private pendingCounts;
|
||||
private errorRateWindow;
|
||||
|
|
@ -20,6 +24,7 @@ export declare class EventQueue {
|
|||
private setupVisibilityListener;
|
||||
private checkInfiniteLoop;
|
||||
enqueue(event: SentryEvent): void;
|
||||
private cleanupExpiredDedupeEntries;
|
||||
private buildCountEvents;
|
||||
private getDedupeKey;
|
||||
flush(): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ export declare class Reporter {
|
|||
*/
|
||||
private stripSharedFields;
|
||||
private getEnvelopeType;
|
||||
private generateEventId;
|
||||
private send;
|
||||
private sendSync;
|
||||
private sendViaImage;
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -4,6 +4,7 @@ import PerformancePlugin from './plugins/PerformancePlugin';
|
|||
import NetworkPlugin from './plugins/NetworkPlugin';
|
||||
import BehaviorPlugin from './plugins/BehaviorPlugin';
|
||||
import OfflinePlugin from './plugins/OfflinePlugin';
|
||||
import SamplingPlugin from './plugins/SamplingPlugin';
|
||||
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;
|
||||
|
|
@ -20,7 +21,7 @@ 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 { init, getClient, captureException, captureMessage, captureEvent, setUser, setTag, setTags, setExtra, addBreadcrumb, flush, disable, enable, Client, ErrorPlugin, PerformancePlugin, NetworkPlugin, BehaviorPlugin, OfflinePlugin, SamplingPlugin, };
|
||||
export type { LightConfig, LightClient, LightPlugin, SentryEvent, ErrorEvent, PerformanceEvent, NetworkEvent, BehaviorEvent, EventLevel, UserInfo, Breadcrumb, DSNInfo, };
|
||||
declare const _default: {
|
||||
init: typeof init;
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -9,6 +9,15 @@ declare class BehaviorPlugin implements LightPlugin {
|
|||
private maxScrollDepth;
|
||||
private pageEnterTime;
|
||||
private scrollReported;
|
||||
private originalPushState?;
|
||||
private originalReplaceState?;
|
||||
private onScroll?;
|
||||
private onClick?;
|
||||
private onVisibilityChange?;
|
||||
private onBeforeUnload?;
|
||||
private onPagehide?;
|
||||
private onPopState?;
|
||||
private onHashchange?;
|
||||
setup(client: LightClient): void;
|
||||
private loadConfig;
|
||||
private shouldSample;
|
||||
|
|
|
|||
|
|
@ -4,15 +4,21 @@ declare class ErrorPlugin implements LightPlugin {
|
|||
version: string;
|
||||
private client;
|
||||
private config;
|
||||
private originalOnError?;
|
||||
private onUnhandledRejection?;
|
||||
private onResourceError?;
|
||||
setup(client: LightClient): void;
|
||||
private loadConfig;
|
||||
private shouldIgnoreError;
|
||||
private shouldIgnoreScriptUrl;
|
||||
private shouldIncludePath;
|
||||
private shouldSample;
|
||||
private shouldSampleResource;
|
||||
private setupGlobalError;
|
||||
private setupUnhandledRejection;
|
||||
private extractUrlFromReason;
|
||||
private setupResourceError;
|
||||
private buildErrorEvent;
|
||||
private parseStackTrace;
|
||||
destroy(): void;
|
||||
}
|
||||
export default ErrorPlugin;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ declare class OfflinePlugin implements LightPlugin {
|
|||
private db;
|
||||
private isOnline;
|
||||
private isSyncing;
|
||||
private onOnline?;
|
||||
private onOffline?;
|
||||
private pendingEvents;
|
||||
setup(client: LightClient): void;
|
||||
private loadConfig;
|
||||
|
|
@ -14,11 +16,13 @@ declare class OfflinePlugin implements LightPlugin {
|
|||
private setupNetworkListeners;
|
||||
private generateId;
|
||||
private saveToIndexedDB;
|
||||
private getEventCount;
|
||||
private deleteOldestEvents;
|
||||
private getAllStoredEvents;
|
||||
private clearStoredEvents;
|
||||
syncPendingEvents(): Promise<void>;
|
||||
beforeReport(event: SentryEvent): SentryEvent | null;
|
||||
private savePendingEventsToDB;
|
||||
destroy(): void;
|
||||
}
|
||||
export default OfflinePlugin;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ declare class PerformancePlugin implements LightPlugin {
|
|||
version: string;
|
||||
private client;
|
||||
private config;
|
||||
private observers;
|
||||
private onLoad?;
|
||||
private navLoadTimer;
|
||||
private totalBlockingTime;
|
||||
private fcpTime;
|
||||
private lastLongTaskEndTime;
|
||||
private ttiReported;
|
||||
private ttiTimer;
|
||||
setup(client: LightClient): void;
|
||||
private loadConfig;
|
||||
private shouldSample;
|
||||
|
|
@ -15,6 +23,7 @@ declare class PerformancePlugin implements LightPlugin {
|
|||
private observeFCP;
|
||||
private observeTTFB;
|
||||
private observeLongTasks;
|
||||
private scheduleTTI;
|
||||
private observeNavigation;
|
||||
private observeResources;
|
||||
private getRating;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import type { LightPlugin, LightClient, SentryEvent } from '../types';
|
||||
declare class SamplingPlugin implements LightPlugin {
|
||||
name: string;
|
||||
version: string;
|
||||
private client;
|
||||
private config;
|
||||
setup(client: LightClient): void;
|
||||
private loadConfig;
|
||||
private shouldSample;
|
||||
private getTypeRate;
|
||||
private randomCheck;
|
||||
beforeReport(event: SentryEvent): SentryEvent | null;
|
||||
destroy(): void;
|
||||
}
|
||||
export default SamplingPlugin;
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
export type EventLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug';
|
||||
export interface ContextLevelConfig {
|
||||
maxStackFrames: number;
|
||||
maxBreadcrumbs: number;
|
||||
}
|
||||
export type ContextLevelMap = Record<EventLevel, ContextLevelConfig>;
|
||||
export interface UserInfo {
|
||||
id?: string;
|
||||
username?: string;
|
||||
|
|
@ -113,6 +118,15 @@ export interface DSNInfo {
|
|||
host: string;
|
||||
projectId: string;
|
||||
}
|
||||
export interface SamplingConfig {
|
||||
rates?: {
|
||||
error?: number;
|
||||
performance?: number;
|
||||
network?: number;
|
||||
behavior?: number;
|
||||
[key: string]: number | undefined;
|
||||
};
|
||||
}
|
||||
export interface LightConfig {
|
||||
dsn: string;
|
||||
release?: string;
|
||||
|
|
@ -126,9 +140,12 @@ export interface LightConfig {
|
|||
ignoreErrors?: (string | RegExp)[];
|
||||
ignoreUrls?: (string | RegExp)[];
|
||||
includePaths?: (string | RegExp)[];
|
||||
maxBreadcrumbs?: number;
|
||||
beforeSend?: (event: SentryEvent) => SentryEvent | null;
|
||||
user?: UserInfo;
|
||||
plugins?: (LightPlugin | string)[];
|
||||
contextLevel?: Partial<ContextLevelMap>;
|
||||
sampling?: SamplingConfig;
|
||||
[pluginName: string]: unknown;
|
||||
}
|
||||
export interface LightPlugin {
|
||||
|
|
@ -158,4 +175,7 @@ export interface LightClient {
|
|||
flush(): Promise<void>;
|
||||
disable(): void;
|
||||
enable(): void;
|
||||
use(plugin: LightPlugin): void;
|
||||
init(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ 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;
|
||||
export declare function matchPatterns(value: string, patterns: (string | RegExp)[]): boolean;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import type { StackFrame } from '../types';
|
||||
export interface ParseStackOptions {
|
||||
captureNodeModules?: boolean;
|
||||
captureColumn?: boolean;
|
||||
relativePathOnly?: boolean;
|
||||
maxFrames?: number;
|
||||
}
|
||||
export declare function parseStackTrace(stack?: string, options?: ParseStackOptions): StackFrame[];
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
# Light-Sentry 轻量级前端监控系统 - 架构设计文档
|
||||
|
||||
## 一、设计理念
|
||||
|
||||
### 1.1 核心原则
|
||||
|
||||
- **轻量优先**:SDK < 10KB (gzip),服务端单节点可运行
|
||||
- **插件化架构**:核心极小,功能通过插件扩展,按需加载
|
||||
- **性能友好**:不阻塞主线程,不影响页面性能
|
||||
- **易于部署**:docker-compose up 一键启动,无需复杂运维
|
||||
- **兼容 Sentry**:接口兼容 Sentry 协议,可平滑迁移
|
||||
|
||||
### 1.2 设计目标
|
||||
|
||||
| 指标 | 目标值 | 说明 |
|
||||
|------|--------|------|
|
||||
| SDK 核心体积 | < 5KB gzip | 只含事件总线和上报 |
|
||||
| SDK 完整体积 | < 15KB gzip | 含错误+性能+网络插件 |
|
||||
| 单节点承载 | 10万+ 事件/天 | 1C2G 服务器 |
|
||||
| 部署时间 | < 5 分钟 | docker-compose 一键部署 |
|
||||
| 数据延迟 | < 30 秒 | 从上报到可查询 |
|
||||
|
||||
---
|
||||
|
||||
## 二、整体架构
|
||||
|
||||
### 2.1 架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 前端 SDK │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────────┐ │
|
||||
│ │ Error │ │ Perf │ │ Network │ │ Behavior │ │
|
||||
│ │ Plugin │ │ Plugin │ │ Plugin │ │ Plugin │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ └──────┬───────┘ │
|
||||
│ └─────────────┴─────────────┴──────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────┴─────┐ │
|
||||
│ │ Core │ │
|
||||
│ │ (EventBus │ │
|
||||
│ │ + Queue │ │
|
||||
│ │ + Report)│ │
|
||||
│ └─────┬─────┘ │
|
||||
└──────────────────────────┼───────────────────────────────────┘
|
||||
│ HTTP (beacon / fetch / img)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Nginx 反向代理 │
|
||||
│ 限流 · CORS · 负载均衡 · SSL 卸载 │
|
||||
└──────────────────────────┬───────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ API 接入层 (Node.js) │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. DSN 认证 (publicKey 校验) │ │
|
||||
│ │ 2. 协议解析 (envelope / store / 自定义) │ │
|
||||
│ │ 3. 数据清洗 (脱敏、过滤、去重) │ │
|
||||
│ │ 4. 错误指纹计算 │ │
|
||||
│ │ 5. 内存队列 + 批量写入 │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└──────────┬───────────────────────────┬──────────────────────┘
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌───────────────────────┐
|
||||
│ Loki (日志) │ │ MySQL (聚合数据) │
|
||||
│ - 原始错误堆栈 │ │ - 项目配置 │
|
||||
│ - 性能指标明细 │ │ - 错误聚合统计 │
|
||||
│ - 用户行为日志 │ │ - 性能指标趋势 │
|
||||
│ 保留: 7-30 天 │ │ - 告警规则 │
|
||||
└─────────────────────┘ │ - 用户管理 │
|
||||
└───────────┬───────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 查询 & 展示层 │
|
||||
│ ┌──────────────┐ ┌──────────┐ ┌──────────────────────┐ │
|
||||
│ │ 管理后台 │ │ 告警引擎 │ │ Grafana 仪表盘 │ │
|
||||
│ │ (原生 JS) │ │ (规则引擎)│ │ (复用现有工具) │ │
|
||||
│ │ - 项目管理 │ │ - 规则 │ │ - 错误趋势图 │ │
|
||||
│ │ - 错误查询 │ │ - 收敛 │ │ - 性能仪表盘 │ │
|
||||
│ │ - 性能分析 │ │ - 通知 │ │ - 自定义面板 │ │
|
||||
│ └──────────────┘ └──────────┘ └──────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 模块职责
|
||||
|
||||
| 层级 | 模块 | 职责 | 技术选型 |
|
||||
|------|------|------|----------|
|
||||
| 采集层 | 前端 SDK | 数据采集、队列、上报 | TypeScript + 插件化 |
|
||||
| 接入层 | API Server | 认证、解析、清洗、入队 | Node.js + Express |
|
||||
| 存储层 | Loki | 原始日志存储 | Grafana Loki |
|
||||
| 存储层 | MySQL | 聚合数据、配置、用户 | MySQL 8.0 / SQLite |
|
||||
| 计算层 | 聚合服务 | 错误聚合、性能统计 | 定时任务 / 实时聚合 |
|
||||
| 计算层 | 告警引擎 | 规则匹配、告警通知 | 内置规则引擎 |
|
||||
| 展示层 | 管理后台 | 项目管理、数据查询 | 原生 HTML + JS |
|
||||
| 展示层 | Grafana | 数据可视化 | Grafana |
|
||||
|
||||
---
|
||||
|
||||
## 三、数据流
|
||||
|
||||
### 3.1 错误上报流程
|
||||
|
||||
```
|
||||
前端触发错误
|
||||
↓
|
||||
ErrorPlugin 捕获
|
||||
↓
|
||||
封装为标准事件格式
|
||||
↓
|
||||
加入内存队列
|
||||
↓
|
||||
批量/定时/页面隐藏 触发上报
|
||||
↓
|
||||
sendBeacon / fetch / img 标签
|
||||
↓
|
||||
Nginx (限流 + CORS)
|
||||
↓
|
||||
API Server
|
||||
├─ DSN 认证
|
||||
├─ envelope 解析
|
||||
├─ 数据清洗 (脱敏IP、UA)
|
||||
├─ 计算错误指纹
|
||||
└─ 写入内存队列
|
||||
↓
|
||||
批量消费
|
||||
├─ 写入 Loki (原始数据)
|
||||
└─ 更新 MySQL 聚合表
|
||||
↓
|
||||
Grafana / 管理后台 查询
|
||||
↓
|
||||
告警引擎 检测规则
|
||||
↓
|
||||
触发通知 (Webhook / 邮件)
|
||||
```
|
||||
|
||||
### 3.2 性能指标流程
|
||||
|
||||
```
|
||||
PerformancePlugin 监听性能指标
|
||||
↓
|
||||
(FP / FCP / LCP / CLS / TTI / Long Task)
|
||||
↓
|
||||
采样过滤 (默认 10% 采样率)
|
||||
↓
|
||||
批量上报
|
||||
↓
|
||||
API Server 接收
|
||||
├─ 数据校验
|
||||
└─ 写入 Loki
|
||||
↓
|
||||
定时聚合任务 (每 5 分钟)
|
||||
├─ 计算 P50 / P90 / P95 / P99
|
||||
├─ 按项目 + 页面 + 小时 聚合
|
||||
└─ 写入 MySQL 性能表
|
||||
↓
|
||||
Grafana 展示趋势图
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、技术选型说明
|
||||
|
||||
### 4.1 为什么用 Loki 而不是 ES/ClickHouse?
|
||||
|
||||
| 特性 | Loki | Elasticsearch | ClickHouse |
|
||||
|------|------|---------------|------------|
|
||||
| 部署复杂度 | 极低(单二进制) | 高(需调优) | 中 |
|
||||
| 资源消耗 | 低(内存 + 磁盘) | 高(内存密集) | 中高 |
|
||||
| 查询性能 | 中等(日志足够) | 高 | 极高 |
|
||||
| 全文检索 | 支持(Label + grep) | 强大 | 支持 |
|
||||
| Grafana 集成 | 原生 | 需配置 | 需配置 |
|
||||
| 适合场景 | 日志、错误追踪 | 全文检索、复杂分析 | OLAP、大数据量 |
|
||||
|
||||
**结论**:轻量级监控,Loki 足够用,运维成本最低,和 Grafana 集成最好。
|
||||
|
||||
### 4.2 为什么用 MySQL 存聚合数据?
|
||||
|
||||
- 成熟稳定,运维简单
|
||||
- 聚合后数据量不大(每天几千~几万行)
|
||||
- 事务支持,数据一致性好
|
||||
- 管理后台查询方便
|
||||
- 小项目甚至可以用 SQLite,零运维
|
||||
|
||||
### 4.3 为什么前端用原生 JS?
|
||||
|
||||
- 后台页面不复杂,原生 JS 足够
|
||||
- 无构建流程,修改即生效
|
||||
- 体积小,加载快
|
||||
- 不依赖前端框架生态,长期维护成本低
|
||||
|
||||
---
|
||||
|
||||
## 五、与现有 Light-Sentry 的关系
|
||||
|
||||
### 5.1 现有能力
|
||||
|
||||
- ✅ Sentry 兼容接口(store / envelope)
|
||||
- ✅ Loki 原始日志存储
|
||||
- ✅ Grafana 仪表盘
|
||||
- ✅ 项目管理后台
|
||||
- ✅ Docker Compose 一键部署
|
||||
- ✅ NEL (Network Error Logging) 支持
|
||||
|
||||
### 5.2 待完善能力
|
||||
|
||||
- 🚧 自研轻量 SDK
|
||||
- 🚧 错误指纹聚合
|
||||
- 🚧 性能指标聚合
|
||||
- 🚧 告警引擎
|
||||
- 🚧 源 map 解析
|
||||
- 🚧 用户会话追踪
|
||||
- 🚧 行为分析插件
|
||||
|
||||
---
|
||||
|
||||
## 六、文档目录
|
||||
|
||||
```
|
||||
docs/
|
||||
├── 00-architecture.md # 本文档 - 整体架构
|
||||
├── 01-sdk-design.md # 前端 SDK 设计
|
||||
├── 02-api-server.md # 服务端接入层设计
|
||||
├── 03-data-aggregation.md # 数据聚合与存储设计
|
||||
├── 04-alert-engine.md # 告警引擎设计
|
||||
├── 05-admin-dashboard.md # 管理后台与可视化设计
|
||||
└── 06-deployment.md # 部署与运维设计
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,492 @@
|
|||
# 服务端接入层设计文档
|
||||
|
||||
## 一、设计原则
|
||||
|
||||
### 1.1 核心原则
|
||||
|
||||
- **高并发**:单节点支撑 1000+ QPS 事件上报
|
||||
- **低延迟**:接口响应 < 50ms(先入队再处理)
|
||||
- **高可用**:进程崩溃不丢数据(本地持久化队列)
|
||||
- **可扩展**:支持横向扩展,无状态设计
|
||||
- **兼容性**:完全兼容 Sentry 协议,SDK 可无缝切换
|
||||
|
||||
### 1.2 性能指标
|
||||
|
||||
| 指标 | 目标值 | 说明 |
|
||||
|------|--------|------|
|
||||
| 单节点 QPS | 1000+ | 4C8G 服务器 |
|
||||
| P99 响应时间 | < 50ms | 99% 请求在 50ms 内返回 |
|
||||
| 数据丢失率 | < 0.01% | 异常情况下的数据丢失率 |
|
||||
| 单事件处理耗时 | < 1ms | 从入队到写入存储 |
|
||||
|
||||
---
|
||||
|
||||
## 二、API 接口设计
|
||||
|
||||
### 2.1 接口列表
|
||||
|
||||
| 端点 | 方法 | 功能 | 优先级 |
|
||||
|------|------|------|--------|
|
||||
| `/api/{projectId}/envelope/` | POST | Envelope 批量上报 | 最高 |
|
||||
| `/api/{projectId}/store/` | POST | 单事件上报 | 高 |
|
||||
| `/api/{projectId}/nel/` | POST | NEL 网络错误上报 | 中 |
|
||||
| `/api/{projectId}/health` | GET | 健康检查 | - |
|
||||
| `/api/projects/` | GET/POST | 项目管理 | - |
|
||||
| `/api/projects/{id}` | GET/PUT/DELETE | 项目 CRUD | - |
|
||||
|
||||
### 2.2 Envelope 接口(主要上报接口)
|
||||
|
||||
#### URL 格式
|
||||
|
||||
```
|
||||
POST /api/{projectId}/envelope/?sentry_key={publicKey}&sentry_version=7
|
||||
```
|
||||
|
||||
#### 请求头
|
||||
|
||||
| Header | 说明 | 必填 |
|
||||
|--------|------|------|
|
||||
| `Content-Type` | `application/x-sentry-envelope` 或 `application/json` | 是 |
|
||||
| `X-Sentry-Auth` | Sentry 认证头(备用方式) | 否 |
|
||||
|
||||
#### 认证方式(二选一)
|
||||
|
||||
**方式 1:URL 参数(推荐,兼容性好)**
|
||||
```
|
||||
?sentry_key={publicKey}&sentry_version=7
|
||||
```
|
||||
|
||||
**方式 2:Header 方式**
|
||||
```
|
||||
X-Sentry-Auth: Sentry sentry_version=7, sentry_key={publicKey}
|
||||
```
|
||||
|
||||
#### Envelope 格式解析
|
||||
|
||||
标准 Envelope 格式:
|
||||
```
|
||||
# 第 1 行:envelope header(JSON)
|
||||
{"event_id":"abc123","sent_at":"2024-01-01T00:00:00Z"}
|
||||
# 第 2 行:item header(JSON)
|
||||
{"type":"event","length":123}
|
||||
# 第 3 行:item payload(JSON,长度 = length)
|
||||
{"level":"error","message":"test",...}
|
||||
# 第 4 行:下一个 item header
|
||||
{"type":"transaction","length":456}
|
||||
# ...
|
||||
```
|
||||
|
||||
支持的 item 类型:
|
||||
|
||||
| type | 说明 | 处理方式 |
|
||||
|------|------|----------|
|
||||
| `event` | 错误事件 | 完整处理 + 写入 Loki + 聚合 |
|
||||
| `transaction` | 性能事务 | 提取关键指标 + 聚合 |
|
||||
| `session` | 会话 | 计数 + 聚合 |
|
||||
| `attachment` | 附件 | 忽略(轻量版不支持) |
|
||||
| `profile` | 性能剖析 | 忽略 |
|
||||
| `statsd` | 客户端统计 | 忽略 |
|
||||
| `user_report` | 用户反馈 | 存储 + 计数 |
|
||||
|
||||
#### 简化格式(自研 SDK 专用)
|
||||
|
||||
为了减少解析开销,自研 SDK 可使用简化格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"type": "error",
|
||||
"data": { ... }
|
||||
},
|
||||
{
|
||||
"type": "performance",
|
||||
"data": { ... }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Content-Type: `application/json`
|
||||
|
||||
### 2.3 Store 接口(兼容旧版 SDK)
|
||||
|
||||
```
|
||||
POST /api/{projectId}/store/?sentry_key={publicKey}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"event_id": "abc123",
|
||||
"level": "error",
|
||||
"message": "...",
|
||||
"exception": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 NEL 接口(Network Error Logging)
|
||||
|
||||
```
|
||||
POST /api/{projectId}/nel/
|
||||
Content-Type: application/reports+json
|
||||
|
||||
[
|
||||
{
|
||||
"age": 123,
|
||||
"type": "network-error",
|
||||
"url": "https://example.com/",
|
||||
"body": {
|
||||
"sampling_fraction": 1.0,
|
||||
"server_ip": "1.2.3.4",
|
||||
"protocol": "h2",
|
||||
"method": "GET",
|
||||
"status_code": 0,
|
||||
"elapsed_time": 123,
|
||||
"type": "dns.failed"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、处理流程
|
||||
|
||||
### 3.1 整体流程
|
||||
|
||||
```
|
||||
HTTP 请求到达
|
||||
↓
|
||||
Nginx (限流 + CORS + 日志)
|
||||
↓
|
||||
1. 认证中间件
|
||||
├─ 解析 projectId (URL)
|
||||
├─ 解析 publicKey (query / header)
|
||||
└─ 校验项目是否存在
|
||||
↓
|
||||
2. 请求体解析
|
||||
├─ 根据 Content-Type 选择解析器
|
||||
├─ envelope 格式 → 逐行解析
|
||||
└─ json 格式 → JSON.parse
|
||||
↓
|
||||
3. 数据清洗
|
||||
├─ 字段校验
|
||||
├─ 敏感数据脱敏
|
||||
├─ 计算错误指纹
|
||||
└─ 补充默认字段
|
||||
↓
|
||||
4. 写入内存队列(立即返回响应)
|
||||
↓
|
||||
5. 异步批量消费
|
||||
├─ 写入 Loki(原始日志)
|
||||
├─ 更新 MySQL 聚合表
|
||||
└─ 触发告警检测
|
||||
```
|
||||
|
||||
### 3.2 认证中间件
|
||||
|
||||
```javascript
|
||||
async function sentryAuth(req, res, next) {
|
||||
// 1. 从 URL 中提取 projectId
|
||||
const projectId = req.params.projectId;
|
||||
|
||||
// 2. 从 URL 参数或 Header 获取 publicKey
|
||||
const publicKey = req.query.sentry_key ||
|
||||
parseSentryAuthHeader(req.headers['x-sentry-auth']);
|
||||
|
||||
// 3. 校验项目是否存在
|
||||
const project = projectStore.findByProjectId(projectId);
|
||||
if (!project) {
|
||||
return res.status(404).json({ detail: 'Project not found' });
|
||||
}
|
||||
|
||||
// 4. 校验 publicKey
|
||||
if (project.publicKey !== publicKey) {
|
||||
return res.status(401).json({ detail: 'Invalid public key' });
|
||||
}
|
||||
|
||||
// 5. 挂载到请求对象
|
||||
req.project = project;
|
||||
next();
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 中间件顺序(重要)
|
||||
|
||||
```
|
||||
express.raw() ← Sentry 路由先挂载,避免 express.json() 干扰
|
||||
↓
|
||||
sentryAuth ← DSN 认证
|
||||
↓
|
||||
envelopeParser ← 解析 envelope 格式
|
||||
↓
|
||||
rateLimit ← 限流
|
||||
↓
|
||||
handler ← 业务处理
|
||||
|
||||
express.json() ← 其他 API 路由
|
||||
↓
|
||||
其他业务中间件
|
||||
```
|
||||
|
||||
**关键设计**:Sentry 上报接口必须在 `express.json()` 之前挂载,否则 raw body 会被解析成 JSON,导致 envelope 格式解析失败。
|
||||
|
||||
---
|
||||
|
||||
## 四、内存队列设计
|
||||
|
||||
### 4.1 队列结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 内存队列 (Array) │
|
||||
│ [ evt1, evt2, evt3, ..., evtN ] │
|
||||
│ ↑ ↑ │
|
||||
│ head(tail) maxSize │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 批量消费定时器 │
|
||||
│ 每 1s 或满 100 条 │
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 批量写入 Loki │
|
||||
│ 批量更新 MySQL │
|
||||
│ 告警检测 │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 队列参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `maxQueueSize` | 10000 | 队列最大长度 |
|
||||
| `flushInterval` | 1000ms | 定时刷间隔 |
|
||||
| `batchSize` | 100 | 每批处理数量 |
|
||||
| `persistInterval` | 5000ms | 持久化间隔(防止丢数据) |
|
||||
|
||||
### 4.3 队列满时的策略
|
||||
|
||||
1. **新事件丢弃最旧的**:FIFO,保证新事件优先
|
||||
2. **采样降级**:队列 > 80% 时,非错误事件自动降采样
|
||||
3. **快速失败**:队列 > 95% 时,直接返回 429
|
||||
4. **本地持久化**:优雅关闭时刷入本地文件
|
||||
|
||||
### 4.4 本地持久化(可选)
|
||||
|
||||
防止进程崩溃丢数据:
|
||||
|
||||
- 使用 LevelDB / SQLite 作为持久化队列
|
||||
- 入队时先写磁盘,再读内存
|
||||
- 启动时从磁盘恢复未处理的事件
|
||||
- 性能影响:QPS 从 1000+ 降到 ~500
|
||||
|
||||
---
|
||||
|
||||
## 五、数据清洗与脱敏
|
||||
|
||||
### 5.1 字段校验
|
||||
|
||||
| 字段 | 校验规则 | 不通过处理 |
|
||||
|------|----------|-----------|
|
||||
| `event_id` | 32 位 hex,可选 | 自动生成 |
|
||||
| `timestamp` | ISO 时间戳,可选 | 用服务器时间 |
|
||||
| `message` | 长度 < 8KB | 截断 |
|
||||
| `exception.stacktrace` | 深度 < 50 帧 | 截断 |
|
||||
| `breadcrumbs` | 数量 < 100 | 截断 |
|
||||
| `extra` | 大小 < 16KB | 截断 |
|
||||
| 总大小 | < 256KB | 拒绝 |
|
||||
|
||||
### 5.2 敏感数据脱敏
|
||||
|
||||
自动脱敏的字段:
|
||||
|
||||
| 字段名(不区分大小写) | 脱敏方式 |
|
||||
|----------------------|----------|
|
||||
| `password`, `passwd`, `pwd` | `***` |
|
||||
| `token`, `access_token`, `refresh_token` | 前 4 位 + `***` |
|
||||
| `secret`, `api_key`, `apikey` | `***` |
|
||||
| `email` | `a***@b.com` |
|
||||
| `phone`, `mobile` | `138****1234` |
|
||||
| `id_card`, `idcard` | `110***********1234` |
|
||||
| `credit_card`, `card_no` | `6222**********1234` |
|
||||
| IP 地址 | 保留前两段(192.168.x.x) |
|
||||
|
||||
### 5.3 错误指纹计算
|
||||
|
||||
用于错误去重和聚合:
|
||||
|
||||
```javascript
|
||||
function computeFingerprint(event) {
|
||||
const { exception } = event;
|
||||
if (!exception) {
|
||||
return md5(event.message || 'unknown');
|
||||
}
|
||||
|
||||
// 提取关键信息
|
||||
const type = exception.type || 'Error';
|
||||
const message = normalizeMessage(exception.value); // 脱敏 + 去变量
|
||||
const frames = exception.stacktrace?.frames || [];
|
||||
|
||||
// 取前 3 个 in_app 栈帧
|
||||
const keyFrames = frames
|
||||
.filter(f => f.in_app !== false)
|
||||
.slice(0, 3)
|
||||
.map(f => `${f.filename}:${f.lineno}`);
|
||||
|
||||
return md5(`${type}:${message}:${keyFrames.join('|')}`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、限流设计
|
||||
|
||||
### 6.1 限流维度
|
||||
|
||||
| 维度 | 默认限制 | 说明 |
|
||||
|------|----------|------|
|
||||
| 每项目每秒 | 100 条 | 项目级 QPS 限制 |
|
||||
| 每项目每天 | 100000 条 | 项目级日配额 |
|
||||
| 每 IP 每秒 | 50 条 | IP 级 QPS 限制 |
|
||||
| 全局限流 | 1000 QPS | 服务器总 QPS |
|
||||
|
||||
### 6.2 限流算法
|
||||
|
||||
- **令牌桶算法**:QPS 限流用令牌桶
|
||||
- **滑动窗口**:日配额用滑动窗口
|
||||
- **内存计数**:单节点足够,分布式需 Redis
|
||||
|
||||
### 6.3 超限处理
|
||||
|
||||
| 超出比例 | 处理方式 |
|
||||
|----------|----------|
|
||||
| < 80% | 正常处理 |
|
||||
| 80% - 100% | 非错误事件降采样 50% |
|
||||
| 100% - 150% | 只保留错误事件,其余丢弃 |
|
||||
| > 150% | 全部丢弃,返回 429 |
|
||||
|
||||
---
|
||||
|
||||
## 七、批量消费设计
|
||||
|
||||
### 7.1 消费流程
|
||||
|
||||
```
|
||||
批次事件
|
||||
↓
|
||||
按项目分组
|
||||
↓
|
||||
按事件类型分组 (error / performance / network / ...)
|
||||
↓
|
||||
并行处理
|
||||
├─ error 类型
|
||||
│ ├─ 写入 Loki(批量)
|
||||
│ ├─ 更新错误聚合表(按指纹分组计数)
|
||||
│ └─ 触发告警检测
|
||||
├─ performance 类型
|
||||
│ ├─ 写入 Loki
|
||||
│ └─ 更新性能指标表(P50/P95/P99)
|
||||
└─ 其他类型
|
||||
└─ 写入 Loki
|
||||
```
|
||||
|
||||
### 7.2 Loki 批量写入
|
||||
|
||||
使用 Loki 的 `/loki/api/v1/push` 接口,批量写入:
|
||||
|
||||
```json
|
||||
{
|
||||
"streams": [
|
||||
{
|
||||
"stream": {
|
||||
"project_id": "1001",
|
||||
"level": "error",
|
||||
"type": "error"
|
||||
},
|
||||
"values": [
|
||||
["<ts_nano>", "<json_line>"],
|
||||
["<ts_nano>", "<json_line>"]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 MySQL 批量更新
|
||||
|
||||
- 使用 `INSERT ... ON DUPLICATE KEY UPDATE`
|
||||
- 按批次聚合后一次性写入
|
||||
- 避免逐条更新,提升性能
|
||||
|
||||
---
|
||||
|
||||
## 八、错误码设计
|
||||
|
||||
| HTTP 状态码 | 说明 | 场景 |
|
||||
|-------------|------|------|
|
||||
| 200 | 成功 | 正常接收 |
|
||||
| 204 | 成功无内容 | 同上,兼容不同 SDK |
|
||||
| 400 | 请求格式错误 | envelope 格式不对、body 为空 |
|
||||
| 401 | 认证失败 | publicKey 错误 |
|
||||
| 404 | 项目不存在 | projectId 无效 |
|
||||
| 413 | 请求体过大 | 超过 256KB |
|
||||
| 429 | 限流 | 超过配额 |
|
||||
| 500 | 服务器错误 | 内部异常 |
|
||||
|
||||
---
|
||||
|
||||
## 九、可观测性
|
||||
|
||||
### 9.1 自身监控指标
|
||||
|
||||
| 指标 | 说明 |
|
||||
|------|------|
|
||||
| `events_received_total` | 接收事件总数 |
|
||||
| `events_received_per_second` | 每秒接收数 |
|
||||
| `events_dropped_total` | 丢弃事件总数 |
|
||||
| `events_processed_total` | 处理成功总数 |
|
||||
| `queue_size` | 当前队列长度 |
|
||||
| `process_duration_ms` | 处理耗时(P50/P95/P99) |
|
||||
| `loki_write_errors_total` | Loki 写入错误数 |
|
||||
| `mysql_write_errors_total` | MySQL 写入错误数 |
|
||||
|
||||
### 9.2 健康检查接口
|
||||
|
||||
```
|
||||
GET /health
|
||||
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"uptime": 86400,
|
||||
"queue_size": 123,
|
||||
"events_processed": 1234567,
|
||||
"loki": "connected",
|
||||
"mysql": "connected"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、横向扩展
|
||||
|
||||
### 10.1 无状态设计
|
||||
|
||||
- API Server 完全无状态
|
||||
- 项目配置缓存,启动时加载,定时刷新
|
||||
- 队列在内存中,扩展时直接加机器
|
||||
|
||||
### 10.2 负载均衡
|
||||
|
||||
- Nginx 层做负载均衡
|
||||
- 按 projectId 一致性哈希路由
|
||||
- 同一项目的事件打到同一台机器(有利于缓存和聚合)
|
||||
|
||||
### 10.3 队列扩展
|
||||
|
||||
- 小流量:内存队列足够
|
||||
- 中流量:加 Redis 作为分布式队列
|
||||
- 大流量:加 Kafka(不推荐,太重了)
|
||||
|
|
@ -0,0 +1,533 @@
|
|||
# 数据聚合与存储设计文档
|
||||
|
||||
## 一、设计原则
|
||||
|
||||
### 1.1 存储分层策略
|
||||
|
||||
```
|
||||
热数据(7天内) ←→ Loki + MySQL(高频查询)
|
||||
温数据(7-30天) ←→ Loki(低频查询)
|
||||
冷数据(30天+) ←→ 对象存储归档(极少查询)
|
||||
```
|
||||
|
||||
### 1.2 存储选型
|
||||
|
||||
| 数据类型 | 存储 | 保留时长 | 说明 |
|
||||
|----------|------|----------|------|
|
||||
| 原始错误事件 | Loki | 7-30 天 | 堆栈、上下文、面包屑 |
|
||||
| 原始性能事件 | Loki | 7 天 | 性能明细数据 |
|
||||
| 原始行为事件 | Loki | 3 天 | PV、点击等(量大) |
|
||||
| 错误聚合统计 | MySQL | 永久 | 按指纹 + 时间聚合 |
|
||||
| 性能指标统计 | MySQL | 90 天 | 按指标 + 时间聚合 |
|
||||
| 项目配置 | MySQL | 永久 | 项目、用户、告警规则 |
|
||||
| 告警记录 | MySQL | 90 天 | 告警历史 |
|
||||
|
||||
### 1.3 设计目标
|
||||
|
||||
- **Loki 存储成本**:100万错误事件 ≈ 5GB/月
|
||||
- **MySQL 存储成本**:聚合数据 ≈ 100MB/月/项目
|
||||
- **查询性能**:聚合查询 < 100ms,原始日志查询 < 2s
|
||||
|
||||
---
|
||||
|
||||
## 二、Loki 存储设计
|
||||
|
||||
### 2.1 Label 设计
|
||||
|
||||
Loki 的 Label 是查询索引,设计原则:**低基数、高区分度**。
|
||||
|
||||
| Label | 说明 | 基数 | 示例 |
|
||||
|-------|------|------|------|
|
||||
| `project_id` | 项目 ID | 低(项目数) | 1001 |
|
||||
| `type` | 事件类型 | 极低 | error / performance / network / behavior |
|
||||
| `level` | 日志级别 | 极低 | fatal / error / warning / info |
|
||||
| `platform` | 平台 | 低 | javascript / node / python |
|
||||
| `environment` | 环境 | 低 | production / staging / development |
|
||||
| `fingerprint` | 错误指纹 | 中 | abc123def |
|
||||
|
||||
**反模式(不要用做 Label)**:
|
||||
- ❌ `event_id`:基数太高,每个事件都不同
|
||||
- ❌ `message`:基数太高,且是文本
|
||||
- ❌ `url`:基数太高
|
||||
- ❌ `user_id`:基数太高
|
||||
|
||||
这些应该放在日志内容里,用 grep 查询。
|
||||
|
||||
### 2.2 日志格式(JSON)
|
||||
|
||||
每条日志是一行 JSON,方便 Loki 的 `json` 解析器提取字段。
|
||||
|
||||
#### 错误事件格式
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "abc123...",
|
||||
"type": "error",
|
||||
"level": "error",
|
||||
"timestamp": 1704067200000,
|
||||
"project_id": "1001",
|
||||
"platform": "javascript",
|
||||
"environment": "production",
|
||||
"release": "1.0.0",
|
||||
"fingerprint": "a1b2c3d4e5f6",
|
||||
|
||||
"message": "Cannot read property 'foo' of undefined",
|
||||
"exception": {
|
||||
"type": "TypeError",
|
||||
"value": "Cannot read property 'foo' of undefined",
|
||||
"stacktrace": {
|
||||
"frames": [
|
||||
{
|
||||
"filename": "https://example.com/app.js",
|
||||
"function": "onClick",
|
||||
"lineno": 123,
|
||||
"colno": 45,
|
||||
"in_app": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"user": {
|
||||
"id": "123",
|
||||
"username": "testuser"
|
||||
},
|
||||
|
||||
"tags": {
|
||||
"page": "/home",
|
||||
"browser": "Chrome 120"
|
||||
},
|
||||
|
||||
"extra": {},
|
||||
"breadcrumbs": [],
|
||||
|
||||
"request": {
|
||||
"url": "https://example.com/page",
|
||||
"headers": {
|
||||
"user_agent": "Mozilla/5.0..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 性能事件格式
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "performance",
|
||||
"level": "info",
|
||||
"timestamp": 1704067200000,
|
||||
"project_id": "1001",
|
||||
|
||||
"metric": "lcp",
|
||||
"value": 2500,
|
||||
"unit": "ms",
|
||||
"rating": "good",
|
||||
|
||||
"tags": {
|
||||
"page_url": "https://example.com/page",
|
||||
"route": "/home",
|
||||
"browser": "Chrome 120",
|
||||
"os": "Mac OS X"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 网络事件格式
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "network",
|
||||
"level": "info",
|
||||
"timestamp": 1704067200000,
|
||||
"project_id": "1001",
|
||||
|
||||
"sub_type": "fetch",
|
||||
"method": "GET",
|
||||
"url": "/api/users",
|
||||
"status_code": 200,
|
||||
"duration": 123,
|
||||
"success": true,
|
||||
|
||||
"tags": {
|
||||
"route": "/home"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Loki 查询示例
|
||||
|
||||
**查询某项目的错误总数(1小时内)**
|
||||
```logql
|
||||
count_over_time(
|
||||
{project_id="1001", type="error"}[1h]
|
||||
)
|
||||
```
|
||||
|
||||
**查询某错误指纹的出现次数**
|
||||
```logql
|
||||
count_over_time(
|
||||
{project_id="1001", fingerprint="a1b2c3d4"}[24h]
|
||||
)
|
||||
```
|
||||
|
||||
**查询某页面的 LCP P95**
|
||||
```logql
|
||||
quantile_over_time(
|
||||
0.95,
|
||||
{project_id="1001", type="performance"}
|
||||
| json value=value
|
||||
| metric="lcp"
|
||||
| unwrap value
|
||||
[5m]
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、MySQL 聚合表设计
|
||||
|
||||
### 3.1 错误聚合表
|
||||
|
||||
#### `error_stats_hourly` - 错误小时统计表
|
||||
|
||||
按错误指纹 + 项目 + 小时聚合,用于趋势图。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | BIGINT PK | 主键 |
|
||||
| `project_id` | VARCHAR(64) | 项目 ID |
|
||||
| `fingerprint` | VARCHAR(64) | 错误指纹 |
|
||||
| `stat_hour` | DATETIME | 统计小时(整点) |
|
||||
| `error_type` | VARCHAR(128) | 错误类型(TypeError 等) |
|
||||
| `error_message` | VARCHAR(512) | 错误消息(截断) |
|
||||
| `count` | INT | 发生次数 |
|
||||
| `affected_users` | INT | 影响用户数(估算) |
|
||||
| `first_seen` | DATETIME | 首次出现时间 |
|
||||
| `last_seen` | DATETIME | 最后出现时间 |
|
||||
| `created_at` | DATETIME | 创建时间 |
|
||||
| `updated_at` | DATETIME | 更新时间 |
|
||||
|
||||
**索引**:
|
||||
- `(project_id, stat_hour)` - 按项目+时间查询
|
||||
- `(project_id, fingerprint, stat_hour)` - 按指纹+时间查询
|
||||
- 唯一键:`(project_id, fingerprint, stat_hour)`
|
||||
|
||||
#### `error_issues` - 错误 Issue 表
|
||||
|
||||
按错误指纹聚合,用于错误列表管理。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | BIGINT PK | 主键 |
|
||||
| `project_id` | VARCHAR(64) | 项目 ID |
|
||||
| `fingerprint` | VARCHAR(64) UNIQUE | 错误指纹 |
|
||||
| `error_type` | VARCHAR(128) | 错误类型 |
|
||||
| `error_message` | TEXT | 错误消息 |
|
||||
| `stack_trace` | TEXT | 堆栈摘要(前几帧) |
|
||||
| `level` | VARCHAR(32) | 级别 |
|
||||
| `platform` | VARCHAR(32) | 平台 |
|
||||
| `total_count` | BIGINT | 总次数 |
|
||||
| `today_count` | INT | 今日次数 |
|
||||
| `yesterday_count` | INT | 昨日次数 |
|
||||
| `affected_users` | INT | 影响用户数 |
|
||||
| `status` | VARCHAR(32) | 状态:active / resolved / ignored |
|
||||
| `assignee` | VARCHAR(64) | 处理人 |
|
||||
| `first_seen` | DATETIME | 首次出现 |
|
||||
| `last_seen` | DATETIME | 最后出现 |
|
||||
| `created_at` | DATETIME | 创建时间 |
|
||||
| `updated_at` | DATETIME | 更新时间 |
|
||||
|
||||
**状态流转**:
|
||||
```
|
||||
active ──→ resolved ──→ active(再次出现时复活)
|
||||
│
|
||||
└──→ ignored
|
||||
```
|
||||
|
||||
### 3.2 性能统计表
|
||||
|
||||
#### `performance_stats_hourly` - 性能小时统计表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | BIGINT PK | 主键 |
|
||||
| `project_id` | VARCHAR(64) | 项目 ID |
|
||||
| `metric` | VARCHAR(32) | 指标名:lcp / fcp / cls / fid |
|
||||
| `stat_hour` | DATETIME | 统计小时 |
|
||||
| `page_url` | VARCHAR(256) | 页面 URL(可选,空=全部) |
|
||||
| `sample_count` | INT | 样本数 |
|
||||
| `p50` | DOUBLE | 中位数 |
|
||||
| `p75` | DOUBLE | 75 分位 |
|
||||
| `p90` | DOUBLE | 90 分位 |
|
||||
| `p95` | DOUBLE | 95 分位 |
|
||||
| `p99` | DOUBLE | 99 分位 |
|
||||
| `avg` | DOUBLE | 平均值 |
|
||||
| `good_rate` | DOUBLE | Good 比例(0-1) |
|
||||
| `poor_rate` | DOUBLE | Poor 比例(0-1) |
|
||||
| `created_at` | DATETIME | 创建时间 |
|
||||
| `updated_at` | DATETIME | 更新时间 |
|
||||
|
||||
**索引**:
|
||||
- `(project_id, metric, stat_hour)` - 主查询索引
|
||||
- 唯一键:`(project_id, metric, stat_hour, page_url)`
|
||||
|
||||
#### `performance_stats_daily` - 性能日统计表
|
||||
|
||||
同上,按天聚合,用于长期趋势。
|
||||
|
||||
### 3.3 网络请求统计表
|
||||
|
||||
#### `network_stats_hourly` - 网络小时统计表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | BIGINT PK | 主键 |
|
||||
| `project_id` | VARCHAR(64) | 项目 ID |
|
||||
| `stat_hour` | DATETIME | 统计小时 |
|
||||
| `method` | VARCHAR(16) | 请求方法 |
|
||||
| `url_pattern` | VARCHAR(512) | URL 模式(归一化后) |
|
||||
| `total_count` | INT | 总请求数 |
|
||||
| `error_count` | INT | 错误数(4xx + 5xx) |
|
||||
| `error_rate` | DOUBLE | 错误率 |
|
||||
| `avg_duration` | DOUBLE | 平均耗时(ms) |
|
||||
| `p50_duration` | DOUBLE | P50 耗时 |
|
||||
| `p95_duration` | DOUBLE | P95 耗时 |
|
||||
| `created_at` | DATETIME | 创建时间 |
|
||||
|
||||
**URL 归一化**:
|
||||
- `/api/users/123` → `/api/users/:id`
|
||||
- `/static/app.abc123.js` → `/static/app.[hash].js`
|
||||
|
||||
### 3.4 环境维度表
|
||||
|
||||
用于减少 Loki 中环境信息的冗余,配合 SDK 的字典编码和会话级共享使用。
|
||||
|
||||
#### `env_dimensions` - 环境维度表
|
||||
|
||||
每条唯一的环境组合只存 1 条,日志中通过 `env_id` 关联。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | BIGINT PK | 维度 ID |
|
||||
| `project_id` | VARCHAR(64) | 项目 ID |
|
||||
| `browser_name` | VARCHAR(32) | 浏览器名称 |
|
||||
| `browser_version` | VARCHAR(32) | 浏览器主版本 |
|
||||
| `os_name` | VARCHAR(32) | 操作系统 |
|
||||
| `os_version` | VARCHAR(32) | OS 主版本 |
|
||||
| `device_family` | VARCHAR(64) | 设备系列 |
|
||||
| `device_model` | VARCHAR(64) | 设备型号 |
|
||||
| `screen_resolution` | VARCHAR(32) | 屏幕分辨率 |
|
||||
| `language` | VARCHAR(16) | 浏览器语言 |
|
||||
| `hash` | VARCHAR(64) UNIQUE | 所有维度的哈希值,用于快速查找 |
|
||||
| `first_seen` | DATETIME | 首次出现 |
|
||||
| `last_seen` | DATETIME | 最后出现 |
|
||||
| `count` | BIGINT | 出现次数(用于热度排序) |
|
||||
|
||||
**唯一键**:`(project_id, hash)`
|
||||
|
||||
**为什么用维度表?**
|
||||
- Loki 的 JSON 日志里只存 `env_id`(1 个数字),不存完整的浏览器/OS/设备信息
|
||||
- 每条日志节省 100-200 字节,百万级事件节省几十 GB
|
||||
- 查询时 JOIN 维度表,或者直接用 Grafana 的变量查询
|
||||
|
||||
**服务端处理流程**:
|
||||
```
|
||||
事件到达 → 计算环境维度的 hash →
|
||||
├─ 已存在 → 取 env_id,更新 last_seen + count
|
||||
└─ 不存在 → 插入新记录,返回新 env_id
|
||||
→ 日志写入 Loki,只带 env_id
|
||||
```
|
||||
|
||||
#### `page_dimensions` - 页面维度表
|
||||
|
||||
同理,页面 URL 也可以做维度化:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | BIGINT PK | 页面 ID |
|
||||
| `project_id` | VARCHAR(64) | 项目 ID |
|
||||
| `url_pattern` | VARCHAR(512) | 归一化后的 URL 模式 |
|
||||
| `path` | VARCHAR(512) | 路由路径 |
|
||||
| `title` | VARCHAR(256) | 页面标题 |
|
||||
| `hash` | VARCHAR(64) UNIQUE | 哈希 |
|
||||
| `count` | BIGINT | 访问次数 |
|
||||
|
||||
#### `api_dimensions` - 接口维度表
|
||||
|
||||
网络请求的 URL、method 等也可以维度化,日志里只存 `api_id`:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | BIGINT PK | 接口 ID |
|
||||
| `project_id` | VARCHAR(64) | 项目 ID |
|
||||
| `method` | VARCHAR(16) | 请求方法(GET/POST/...) |
|
||||
| `url_pattern` | VARCHAR(512) | 归一化后的 URL 模式 |
|
||||
| `domain` | VARCHAR(128) | 域名 |
|
||||
| `hash` | VARCHAR(64) UNIQUE | method + url_pattern 的哈希 |
|
||||
| `total_count` | BIGINT | 总请求数 |
|
||||
| `error_count` | BIGINT | 错误数 |
|
||||
| `avg_duration` | DOUBLE | 平均耗时 |
|
||||
| `last_seen` | DATETIME | 最后出现时间 |
|
||||
|
||||
**为什么要做接口维度化?**
|
||||
- 日志里只存 `api_id`(8 字节),不用存完整 URL
|
||||
- URL 归一化后,同一路由的不同参数只会产生 1 条维度记录
|
||||
- 按接口统计错误率、耗时等指标时,直接 JOIN 维度表即可
|
||||
|
||||
### 3.5 项目配置表
|
||||
|
||||
#### `projects` - 项目表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | VARCHAR(64) PK | 内部 ID(proj_xxx) |
|
||||
| `project_id` | VARCHAR(32) UNIQUE | Sentry 项目 ID(纯数字) |
|
||||
| `public_key` | VARCHAR(64) | 公钥(默认=内部 ID) |
|
||||
| `name` | VARCHAR(128) | 项目名称 |
|
||||
| `platform` | VARCHAR(32) | 平台 |
|
||||
| `environment` | VARCHAR(32) | 默认环境 |
|
||||
| `description` | TEXT | 描述 |
|
||||
| `status` | VARCHAR(32) | 状态:active / disabled |
|
||||
| `rate_limit` | INT | 速率限制(事件/天) |
|
||||
| `sample_rate` | DOUBLE | 采样率(0-1) |
|
||||
| `created_at` | DATETIME | 创建时间 |
|
||||
| `updated_at` | DATETIME | 更新时间 |
|
||||
|
||||
#### `alert_rules` - 告警规则表
|
||||
|
||||
见告警引擎设计文档。
|
||||
|
||||
---
|
||||
|
||||
## 四、聚合任务设计
|
||||
|
||||
### 4.1 聚合方式
|
||||
|
||||
| 方式 | 实时性 | 复杂度 | 适用场景 |
|
||||
|------|--------|--------|----------|
|
||||
| **实时增量聚合** | 秒级 | 中 | 错误计数、Issue 更新 |
|
||||
| **定时批量聚合** | 分钟级 | 低 | 性能分位数、小时统计 |
|
||||
| **离线重算** | 天级 | 高 | 数据修正、历史回刷 |
|
||||
|
||||
### 4.2 实时增量聚合(错误计数)
|
||||
|
||||
每次事件处理时,直接更新 MySQL:
|
||||
|
||||
```sql
|
||||
INSERT INTO error_issues
|
||||
(project_id, fingerprint, error_type, error_message,
|
||||
total_count, today_count, last_seen, first_seen, status)
|
||||
VALUES
|
||||
(?, ?, ?, ?, 1, 1, NOW(), NOW(), 'active')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
total_count = total_count + 1,
|
||||
today_count = today_count + 1,
|
||||
last_seen = NOW(),
|
||||
status = CASE WHEN status = 'resolved' THEN 'active' ELSE status END;
|
||||
```
|
||||
|
||||
**优点**:实时性好,数据立刻可见
|
||||
**缺点**:高并发下 MySQL 压力大
|
||||
|
||||
**优化**:
|
||||
- 内存中先做 1 秒级别的微批聚合,再批量写 MySQL
|
||||
- 使用 Redis 做计数缓冲,定期刷入 MySQL
|
||||
|
||||
### 4.3 定时批量聚合(性能统计)
|
||||
|
||||
使用 Cron 定时任务,每 5 分钟从 Loki 拉取数据聚合:
|
||||
|
||||
```
|
||||
每 5 分钟执行:
|
||||
1. 从 Loki 查询过去 5 分钟的性能事件
|
||||
2. 按 project + metric + 页面 分组
|
||||
3. 计算 P50/P90/P95/avg 等指标
|
||||
4. 写入 performance_stats_hourly 表
|
||||
```
|
||||
|
||||
**为什么用定时任务而不是实时?**
|
||||
- 性能指标不需要秒级实时
|
||||
- 分位数计算需要一定数据量才准确
|
||||
- 定时批量更省资源
|
||||
|
||||
### 4.4 数据过期与归档
|
||||
|
||||
#### Loki 数据过期
|
||||
|
||||
通过 Loki 的 `retention` 配置自动删除:
|
||||
|
||||
```yaml
|
||||
limits_config:
|
||||
retention_period: 168h # 7 天
|
||||
```
|
||||
|
||||
#### MySQL 数据过期
|
||||
|
||||
- 小时统计表:保留 30 天
|
||||
- 日统计表:保留 1 年
|
||||
- 错误 Issue 表:永久保留(只存聚合数据,量很小)
|
||||
|
||||
定时任务每天凌晨清理过期数据。
|
||||
|
||||
---
|
||||
|
||||
## 五、数据迁移与兼容
|
||||
|
||||
### 5.1 当前状态
|
||||
|
||||
目前项目使用 JSON 文件存储项目配置,Loki 存储原始日志,没有 MySQL 聚合层。
|
||||
|
||||
### 5.2 演进路径
|
||||
|
||||
**阶段 1:JSON → SQLite(单机版)**
|
||||
- 零依赖,开箱即用
|
||||
- 适合个人项目、小团队
|
||||
- 单节点足够
|
||||
|
||||
**阶段 2:SQLite → MySQL(生产版)**
|
||||
- 支持并发
|
||||
- 性能更好
|
||||
- 适合多项目、中大型团队
|
||||
|
||||
**阶段 3:增加 ClickHouse(大规模)**
|
||||
- 亿级数据量
|
||||
- 复杂分析查询
|
||||
- 一般不需要
|
||||
|
||||
---
|
||||
|
||||
## 六、Grafana 数据源配置
|
||||
|
||||
### 6.1 Loki 数据源
|
||||
|
||||
- URL: `http://loki:3100`
|
||||
- 开启 JSON 解析
|
||||
- 配置 Derived fields(从日志跳转到追踪)
|
||||
|
||||
### 6.2 MySQL 数据源
|
||||
|
||||
- 用于展示聚合数据(性能趋势、错误趋势)
|
||||
- 比 Loki 查询更稳定、更快
|
||||
|
||||
---
|
||||
|
||||
## 七、数据安全
|
||||
|
||||
### 7.1 数据脱敏
|
||||
|
||||
- 入库前脱敏:SDK 端 + 服务端双重脱敏
|
||||
- 查询时脱敏:敏感字段查询结果自动打码
|
||||
- 导出时脱敏:导出数据默认脱敏
|
||||
|
||||
### 7.2 数据隔离
|
||||
|
||||
- 项目间完全隔离(通过 project_id label)
|
||||
- 查询时强制带 project_id 条件
|
||||
- 管理后台有项目权限控制
|
||||
|
||||
### 7.3 数据备份
|
||||
|
||||
- MySQL:每日全量备份 + binlog 增量备份
|
||||
- Loki:定期快照到对象存储
|
||||
- 配置文件:Git 版本管理
|
||||
|
|
@ -0,0 +1,451 @@
|
|||
# 告警引擎设计文档
|
||||
|
||||
## 一、设计原则
|
||||
|
||||
### 1.1 核心原则
|
||||
|
||||
- **快**:错误发生后 1 分钟内触发告警
|
||||
- **准**:减少误报,告警要有价值
|
||||
- **全**:支持多种告警规则和通知渠道
|
||||
- **轻**:不依赖复杂组件,轻量实现
|
||||
|
||||
### 1.2 告警目标
|
||||
|
||||
- 错误突增:及时发现线上故障
|
||||
- 新错误出现:第一时间感知新问题
|
||||
- 错误率超标:质量红线监控
|
||||
- 性能劣化:用户体验下降预警
|
||||
|
||||
---
|
||||
|
||||
## 二、告警规则类型
|
||||
|
||||
### 2.1 规则分类
|
||||
|
||||
| 类型 | 说明 | 实时性 | 复杂度 |
|
||||
|------|------|--------|--------|
|
||||
| **阈值告警** | 指标超过固定阈值 | 高 | 低 |
|
||||
| **突增告警** | 同比/环比增长超过阈值 | 中 | 中 |
|
||||
| **新错误告警** | 出现新的错误指纹 | 高 | 低 |
|
||||
| **错误率告警** | 错误率超过阈值 | 中 | 中 |
|
||||
| **质量分告警** | 性能/质量综合评分下降 | 低 | 高 |
|
||||
|
||||
### 2.2 内置规则模板
|
||||
|
||||
#### 1. 错误数量突增
|
||||
|
||||
```yaml
|
||||
name: 错误数量突增
|
||||
type: spike
|
||||
metric: error_count
|
||||
window: 5m # 检测窗口
|
||||
compare: 1h_ago # 对比:1小时前
|
||||
threshold: 200% # 增长 200% 触发
|
||||
min_count: 10 # 最少 10 条才检测(避免噪音)
|
||||
level: warning
|
||||
```
|
||||
|
||||
#### 2. 新错误出现
|
||||
|
||||
```yaml
|
||||
name: 新错误出现
|
||||
type: new_error
|
||||
metric: error_fingerprint
|
||||
window: 24h # 过去 24 小时没出现过
|
||||
level: info
|
||||
```
|
||||
|
||||
#### 3. JS 错误率过高
|
||||
|
||||
```yaml
|
||||
name: JS 错误率过高
|
||||
type: threshold
|
||||
metric: error_rate # 错误数 / PV
|
||||
window: 5m
|
||||
threshold: 5% # 错误率超过 5%
|
||||
level: critical
|
||||
```
|
||||
|
||||
#### 4. 页面性能劣化
|
||||
|
||||
```yaml
|
||||
name: LCP 性能劣化
|
||||
type: threshold
|
||||
metric: lcp_p95
|
||||
window: 15m
|
||||
threshold: 4000 # P95 LCP > 4s
|
||||
level: warning
|
||||
```
|
||||
|
||||
#### 5. 接口错误率过高
|
||||
|
||||
```yaml
|
||||
name: 接口错误率过高
|
||||
type: threshold
|
||||
metric: api_error_rate
|
||||
window: 5m
|
||||
threshold: 10%
|
||||
level: critical
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、告警引擎架构
|
||||
|
||||
### 3.1 整体架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ 事件流 │
|
||||
│ 错误事件 ──→ 实时检测 ──→ 触发规则 ──→ 告警通知 │
|
||||
│ (快路径) │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ 定时任务 │
|
||||
│ Loki/MySQL ──→ 指标计算 ──→ 规则匹配 ──→ 告警通知 │
|
||||
│ (慢路径) │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 双路径设计
|
||||
|
||||
#### 快路径:实时检测(秒级)
|
||||
|
||||
用于:新错误出现、错误数突增(粗粒度)
|
||||
|
||||
```
|
||||
事件入队
|
||||
↓
|
||||
实时规则引擎
|
||||
├─ 检查是否是新指纹(内存 BloomFilter)
|
||||
├─ 检查 1 分钟错误数是否突增
|
||||
└─ 触发 → 入告警队列
|
||||
↓
|
||||
告警收敛 + 去重
|
||||
↓
|
||||
通知
|
||||
```
|
||||
|
||||
#### 慢路径:定时检测(分钟级)
|
||||
|
||||
用于:复杂计算、分位数、错误率
|
||||
|
||||
```
|
||||
定时任务(每 5 分钟)
|
||||
↓
|
||||
从 Loki/MySQL 拉取指标
|
||||
↓
|
||||
计算同比、环比、分位数
|
||||
↓
|
||||
匹配告警规则
|
||||
↓
|
||||
触发 → 入告警队列
|
||||
↓
|
||||
告警收敛 + 去重
|
||||
↓
|
||||
通知
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、告警规则引擎
|
||||
|
||||
### 4.1 规则数据结构
|
||||
|
||||
```sql
|
||||
CREATE TABLE alert_rules (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
project_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- 规则类型
|
||||
type VARCHAR(32) NOT NULL, -- threshold / spike / new_error / error_rate
|
||||
|
||||
-- 规则配置(JSON)
|
||||
config JSON NOT NULL,
|
||||
/*
|
||||
{
|
||||
"metric": "error_count",
|
||||
"window": "5m",
|
||||
"threshold": 100,
|
||||
"compare": "1h_ago",
|
||||
"operator": ">"
|
||||
}
|
||||
*/
|
||||
|
||||
-- 告警级别
|
||||
level VARCHAR(32) NOT NULL, -- info / warning / critical
|
||||
|
||||
-- 通知渠道
|
||||
channels JSON NOT NULL,
|
||||
/*
|
||||
[
|
||||
{ "type": "webhook", "url": "https://..." },
|
||||
{ "type": "email", "to": ["a@b.com"] }
|
||||
]
|
||||
*/
|
||||
|
||||
-- 收敛配置
|
||||
group_by VARCHAR(64), -- 按指纹/项目/页面分组
|
||||
interval INT DEFAULT 300, -- 同组告警间隔(秒)
|
||||
max_count INT DEFAULT 10, -- 每小时最多告警次数
|
||||
|
||||
-- 状态
|
||||
enabled TINYINT DEFAULT 1,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
|
||||
INDEX idx_project (project_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 4.2 规则匹配流程
|
||||
|
||||
```
|
||||
新指标数据到达
|
||||
↓
|
||||
加载项目的所有启用规则
|
||||
↓
|
||||
逐条匹配
|
||||
├─ 类型匹配?
|
||||
├─ 条件满足?
|
||||
└─ 未被静默?
|
||||
↓
|
||||
触发告警
|
||||
↓
|
||||
告警去重 + 收敛
|
||||
↓
|
||||
发送通知
|
||||
```
|
||||
|
||||
### 4.3 内置规则表达式
|
||||
|
||||
支持简单的表达式语法:
|
||||
|
||||
```
|
||||
# 阈值比较
|
||||
error_count > 100
|
||||
error_rate > 0.05
|
||||
lcp_p95 >= 4000
|
||||
|
||||
# 同比环比
|
||||
error_count / error_count_1h_ago > 2
|
||||
error_count > error_count_1d_ago * 1.5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、告警收敛与降噪
|
||||
|
||||
### 5.1 降噪策略
|
||||
|
||||
| 策略 | 说明 | 效果 |
|
||||
|------|------|------|
|
||||
| **同指纹去重** | 同一错误 5 分钟内只告警 1 次 | 减少 80% 重复告警 |
|
||||
| **分组收敛** | 按项目/级别聚合,合并发送 | 减少通知数量 |
|
||||
| **频率限制** | 每项目每小时最多 N 条 | 防止告警风暴 |
|
||||
| **静默期** | 已知问题可设置静默 | 忽略已知问题 |
|
||||
| **最小阈值** | 数量太少不告警 | 避免噪音 |
|
||||
|
||||
### 5.2 告警去重键
|
||||
|
||||
```
|
||||
去重键 = project_id + rule_id + fingerprint + 时间窗口
|
||||
```
|
||||
|
||||
同一去重键在 `interval` 时间内只发 1 次告警。
|
||||
|
||||
### 5.3 告警升级
|
||||
|
||||
- 持续时间超过 30 分钟 → 级别升级(warning → critical)
|
||||
- 影响用户数超过阈值 → 级别升级
|
||||
- 持续超过 2 小时 → 通知更多人
|
||||
|
||||
---
|
||||
|
||||
## 六、通知渠道
|
||||
|
||||
### 6.1 支持的渠道
|
||||
|
||||
| 渠道 | 说明 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| **Webhook** | 通用 HTTP 回调 | 飞书、钉钉、企业微信、Slack |
|
||||
| **邮件** | SMTP 发送 | 正式通知、归档 |
|
||||
| **Server酱** | 微信推送 | 个人项目 |
|
||||
| **飞书机器人** | 专用适配 | 飞书团队 |
|
||||
| **钉钉机器人** | 专用适配 | 钉钉团队 |
|
||||
|
||||
### 6.2 Webhook 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"alert_id": "alert_abc123",
|
||||
"rule_id": 123,
|
||||
"rule_name": "错误数量突增",
|
||||
"level": "critical",
|
||||
"project_id": "1001",
|
||||
"project_name": "前端项目",
|
||||
|
||||
"metric": "error_count",
|
||||
"value": 250,
|
||||
"threshold": 100,
|
||||
"window": "5m",
|
||||
|
||||
"description": "5 分钟内错误数达到 250,超过阈值 100,增长 150%",
|
||||
|
||||
"details": {
|
||||
"error_type": "TypeError",
|
||||
"top_errors": [
|
||||
{ "fingerprint": "a1b2c3", "message": "...", "count": 100 },
|
||||
{ "fingerprint": "d4e5f6", "message": "...", "count": 50 }
|
||||
]
|
||||
},
|
||||
|
||||
"link": "https://log.example.com/manage/#/errors?project=1001",
|
||||
"timestamp": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 飞书 / 钉钉适配
|
||||
|
||||
提供现成的模板,直接粘贴 Webhook URL 即可使用:
|
||||
|
||||
- 飞书:使用富文本卡片,支持点击跳转
|
||||
- 钉钉:使用 Markdown 格式,支持 @人
|
||||
|
||||
---
|
||||
|
||||
## 七、告警事件存储
|
||||
|
||||
### 7.1 告警历史表
|
||||
|
||||
```sql
|
||||
CREATE TABLE alert_events (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
project_id VARCHAR(64) NOT NULL,
|
||||
rule_id BIGINT NOT NULL,
|
||||
rule_name VARCHAR(128),
|
||||
level VARCHAR(32),
|
||||
|
||||
-- 告警数据
|
||||
metric VARCHAR(64),
|
||||
value DOUBLE,
|
||||
threshold DOUBLE,
|
||||
description TEXT,
|
||||
details JSON,
|
||||
|
||||
-- 状态
|
||||
status VARCHAR(32) DEFAULT 'firing', -- firing / resolved / acknowledged
|
||||
|
||||
-- 时间
|
||||
started_at DATETIME,
|
||||
resolved_at DATETIME NULL,
|
||||
duration INT, -- 持续时间(秒)
|
||||
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
|
||||
INDEX idx_project_time (project_id, started_at),
|
||||
INDEX idx_status (status)
|
||||
);
|
||||
```
|
||||
|
||||
### 7.2 状态流转
|
||||
|
||||
```
|
||||
firing ──→ acknowledged ──→ resolved
|
||||
│ ↑
|
||||
└───────────────────────────┘
|
||||
自动恢复(指标降下来)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、实现方案
|
||||
|
||||
### 8.1 轻量实现(v1)
|
||||
|
||||
**技术栈**:Node.js + 内存定时器 + MySQL
|
||||
|
||||
```
|
||||
API Server 进程内
|
||||
├─ 事件消费时同步检测(快路径)
|
||||
│ ├─ 新错误检测(BloomFilter)
|
||||
│ └─ 简单计数(1min 滑动窗口)
|
||||
│
|
||||
└─ 定时任务(慢路径)
|
||||
└─ 每 5 分钟跑一次复杂规则
|
||||
```
|
||||
|
||||
**优点**:无额外组件,部署简单
|
||||
**缺点**:单进程,无法水平扩展
|
||||
|
||||
### 8.2 独立服务(v2)
|
||||
|
||||
**技术栈**:独立的 Alert Worker 进程 + Redis
|
||||
|
||||
```
|
||||
API Server → Redis Stream → Alert Worker
|
||||
→ 规则匹配
|
||||
→ 告警收敛
|
||||
→ 通知发送
|
||||
```
|
||||
|
||||
**优点**:可独立扩展,不影响接入性能
|
||||
**缺点**:多一个组件
|
||||
|
||||
---
|
||||
|
||||
## 九、静默与抑制
|
||||
|
||||
### 9.1 静默规则
|
||||
|
||||
用户可以设置静默期,暂时忽略某些告警:
|
||||
|
||||
```yaml
|
||||
- project_id: 1001
|
||||
fingerprint: a1b2c3d4
|
||||
reason: "已知问题,下个版本修复"
|
||||
start_time: 2024-01-01 00:00:00
|
||||
end_time: 2024-01-03 00:00:00
|
||||
created_by: user1
|
||||
```
|
||||
|
||||
### 9.2 抑制规则
|
||||
|
||||
高优先级告警抑制低优先级:
|
||||
|
||||
- critical 级别的错误告警触发后,同指纹的 warning 级告警被抑制
|
||||
- 项目级别的大故障告警触发后,该项目的其他告警被抑制
|
||||
|
||||
---
|
||||
|
||||
## 十、与现有系统集成
|
||||
|
||||
### 10.1 当前状态
|
||||
|
||||
- 有 Loki 存原始数据 → 可用于慢路径查询
|
||||
- 有 MySQL(待加聚合层)→ 可存规则和告警历史
|
||||
- 有管理后台 → 可加告警管理页面
|
||||
|
||||
### 10.2 落地步骤
|
||||
|
||||
**v1(最小可用)**:
|
||||
1. 新增 alert_rules 和 alert_events 表
|
||||
2. 实现新错误检测(快路径,内存 BloomFilter)
|
||||
3. 实现 Webhook 通知
|
||||
4. 管理后台加告警规则配置页面
|
||||
|
||||
**v2(增强)**:
|
||||
1. 实现定时规则引擎(慢路径)
|
||||
2. 增加突增检测、错误率检测
|
||||
3. 支持飞书/钉钉专用模板
|
||||
4. 告警收敛和静默功能
|
||||
|
||||
**v3(完善)**:
|
||||
1. 独立 Alert Worker 服务
|
||||
2. Redis 队列 + 分布式锁
|
||||
3. 更复杂的规则表达式
|
||||
4. 告警抑制和升级
|
||||
|
|
@ -0,0 +1,916 @@
|
|||
# 管理后台与可视化设计文档
|
||||
|
||||
## 一、设计原则
|
||||
|
||||
### 1.1 核心理念
|
||||
|
||||
**克制、高效、数据为先**
|
||||
|
||||
- 不做花哨的动效,所有设计都服务于「快速定位问题」
|
||||
- 深色主题为主,长时间盯着不累眼
|
||||
- 信息分层明确,重要的信息一眼看到,次要的信息收起来
|
||||
- 操作路径短:3 次点击以内能到达任何核心页面
|
||||
|
||||
### 1.2 技术选型
|
||||
|
||||
| 层级 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 框架 | React 18 | 生态成熟,社区活跃 |
|
||||
| 构建 | Vite 5 | 快,开发体验好 |
|
||||
| 语言 | TypeScript | 类型安全,减少 bug |
|
||||
| UI 库 | Ant Design 5 | 后台管理组件最全,深色主题好 |
|
||||
| 路由 | React Router v6 | 官方推荐,功能完整 |
|
||||
| 状态管理 | Zustand | 轻量,比 Redux 简单太多 |
|
||||
| 数据请求 | TanStack Query (React Query) | 缓存、重发、分页都有了 |
|
||||
| 图表 | ECharts 5 | 功能强大,国内用得多 |
|
||||
| HTTP 客户端 | Axios | 拦截器、取消请求都方便 |
|
||||
| 样式 | CSS Modules + Less | 按需定制主题 |
|
||||
| 代码规范 | ESLint + Prettier | 统一代码风格 |
|
||||
|
||||
### 1.3 为什么选 Ant Design
|
||||
|
||||
- **组件最丰富**:表格、表单、弹窗、树形...后台需要的都有
|
||||
- **深色主题好**:AntD 5 原生支持 ConfigProvider 切换主题
|
||||
- **ProComponents**:ProTable、ProForm 等高级组件,开发效率翻倍
|
||||
- **生态成熟**:遇到问题搜一下就有答案
|
||||
- **设计语言统一**:有自己的设计规范,不用从零定
|
||||
|
||||
### 1.3 页面清单
|
||||
|
||||
| 页面 | 路径 | 优先级 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| 项目列表 | `/manage/` | P0 | 项目管理(已有) |
|
||||
| 项目概览 | `#/overview` | P0 | 项目总览、关键指标 |
|
||||
| 错误列表 | `#/errors` | P0 | 错误 Issue 列表、详情 |
|
||||
| 错误详情 | `#/errors/:id` | P0 | 单个错误的详细分析 |
|
||||
| 性能分析 | `#/performance` | P1 | Web Vitals、性能趋势 |
|
||||
| 网络分析 | `#/network` | P2 | 接口请求统计 |
|
||||
| 行为分析 | `#/behavior` | P2 | PV、用户行为 |
|
||||
| 告警中心 | `#/alerts` | P1 | 告警规则、告警历史 |
|
||||
| 日志查询 | `#/logs` | P1 | Loki 原始日志查询 |
|
||||
| 接入指南 | `#/integration` | P0 | SDK 接入文档(已有) |
|
||||
| 项目设置 | `#/settings` | P1 | 项目配置、成员、采样率 |
|
||||
|
||||
---
|
||||
|
||||
## 二、设计系统
|
||||
|
||||
### 2.1 配色系统
|
||||
|
||||
**主色调**:蓝色系(专业、可信赖)
|
||||
|
||||
| 用途 | 颜色 | 色值 |
|
||||
|------|------|------|
|
||||
| 品牌主色 | 亮蓝 | `#0090f9` |
|
||||
| 品牌主色(悬停) | 蓝 | `#1da1f2` |
|
||||
| 成功 | 绿 | `#26a641` |
|
||||
| 警告 | 黄 | `#e9c46a` |
|
||||
| 错误 | 红 | `#e63946` |
|
||||
| 信息 | 紫 | `#a855f7` |
|
||||
|
||||
**中性色(深色主题)**:
|
||||
|
||||
| 用途 | 色值 | 说明 |
|
||||
|------|------|------|
|
||||
| 背景(最深) | `#0d1117` | 页面底色 |
|
||||
| 背景(卡片) | `#161b22` | 卡片、弹窗 |
|
||||
| 背景(悬浮) | `#21262d` | hover、选中 |
|
||||
| 边框 | `#30363d` | 分割线、边框 |
|
||||
| 文字(主) | `#e6edf3` | 标题、正文 |
|
||||
| 文字(次) | `#8b949e` | 辅助说明、时间 |
|
||||
| 文字(弱) | `#6e7681` | 占位符、disabled |
|
||||
|
||||
### 2.2 字体与间距
|
||||
|
||||
**字体栈**:
|
||||
```css
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
```
|
||||
|
||||
**字号阶梯**:
|
||||
|
||||
| 用途 | 大小 | 字重 |
|
||||
|------|------|------|
|
||||
| 页面大标题 | 24px | 600 |
|
||||
| 区块标题 | 18px | 600 |
|
||||
| 卡片标题 | 16px | 600 |
|
||||
| 正文 | 14px | 400 |
|
||||
| 辅助文字 | 12px | 400 |
|
||||
|
||||
**间距阶梯**(4px 基准):
|
||||
`4px / 8px / 12px / 16px / 20px / 24px / 32px / 48px`
|
||||
|
||||
### 2.3 圆角与阴影
|
||||
|
||||
| 元素 | 圆角 | 阴影 |
|
||||
|------|------|------|
|
||||
| 按钮 | 6px | 无 |
|
||||
| 输入框 | 6px | 无 |
|
||||
| 卡片 | 8px | 无(用边框区分) |
|
||||
| 弹窗 | 12px | `0 8px 24px rgba(0,0,0,0.4)` |
|
||||
| 下拉菜单 | 8px | `0 4px 12px rgba(0,0,0,0.3)` |
|
||||
|
||||
---
|
||||
|
||||
## 三、整体布局
|
||||
|
||||
### 3.1 布局结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Top Bar(64px) │
|
||||
│ ┌──────┐ ┌──────────────┐ ┌─────────────┐ │
|
||||
│ │ Logo │ │ 项目选择器 ▼ │ │ 🔔 👤 设置 │ │
|
||||
│ └──────┘ └──────────────┘ └─────────────┘ │
|
||||
├────────┬────────────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ Side │ Content Area │
|
||||
│ Nav │ ┌──────────────────────────────────────────────────┐ │
|
||||
│(64px │ │ Page Header(标题 + 操作 + 筛选) │ │
|
||||
│ 展开 │ └──────────────────────────────────────────────────┘ │
|
||||
│ 220px)│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ │ Metric Cards(指标卡片行) │ │
|
||||
│ │ └──────────────────────────────────────────────────┘ │
|
||||
│ │ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ │ Charts / Tables(主内容区) │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ │ │
|
||||
│ │ └──────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
└────────┴────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 侧边栏(可折叠)
|
||||
|
||||
**收起态(64px)**:只显示图标
|
||||
```
|
||||
📊 ← 概览
|
||||
🐛 ← 错误
|
||||
⚡ ← 性能
|
||||
🌐 ← 网络
|
||||
📈 ← 看板
|
||||
🔔 ← 告警
|
||||
⚙️ ← 设置
|
||||
```
|
||||
|
||||
**展开态(220px)**:图标 + 文字 + 角标
|
||||
```
|
||||
📊 概览
|
||||
🐛 错误 12 ← 未解决错误数
|
||||
⚡ 性能 3 ← 有性能告警
|
||||
🌐 网络
|
||||
📈 看板
|
||||
🔔 告警 5 ← 未读告警
|
||||
⚙️ 设置
|
||||
```
|
||||
|
||||
### 3.3 顶部栏
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ◀▶ 🪲 Light-Sentry [▼ 前端项目 - my-app ] 🔔 ⚙️ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 左侧:折叠按钮 + Logo + 产品名
|
||||
- 中间:项目选择器(下拉,可搜索)
|
||||
- 右侧:告警铃(有未读红点) + 设置
|
||||
|
||||
### 3.4 页面头(每个页面都有)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 错误 Issues [时间范围: 24h ▼] [搜索] │
|
||||
│ 12 个未解决 · 3 个今日新增 [导出] [刷新] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、核心页面详细设计
|
||||
|
||||
### 4.1 概览页(Overview)
|
||||
|
||||
**定位**:一屏看完项目健康状况,有问题快速跳转到对应页面
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 概览 [24h ▼] [环境: 全部 ▼] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ 错误数 │ │ 错误率 │ │ 影响用户│ │ LCP P95 │ │ CLS P95 │ │
|
||||
│ │ 128 │ │ 2.3% │ │ 1,234 │ │ 3.2s │ │ 0.15 │ │
|
||||
│ │ ↑15% │ │ ↑0.8% │ │ ↑12% │ │ 🔴 差 │ │ 🟡 中 │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ ┌───────────────────────────────┐ │
|
||||
│ │ 错误趋势(24h) │ │ Top 5 错误 │ │
|
||||
│ │ ╱╲ │ │ 1. TypeError: Cannot read... │ │
|
||||
│ │ ╱ ╲ ╱╲ │ │ 2. AxiosError: 500 Inter... │ │
|
||||
│ │ ╱╲╱ ╲╱ ╲ │ │ 3. ReferenceError: x is ... │ │
|
||||
│ │ │ │ 4. ChunkLoadError: Loadin... │ │
|
||||
│ │ │ │ 5. TypeError: Cannot set... │ │
|
||||
│ └──────────────────────────┘ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ ┌───────────────────────────────┐ │
|
||||
│ │ 性能趋势(LCP/FID/CLS) │ │ 新错误(今日新增) │ │
|
||||
│ │ 🟢 LCP 2.5s │ │ • TypeError: Cannot read... │ │
|
||||
│ │ 🟡 FID 120ms │ │ • AxiosError: Network Er... │ │
|
||||
│ │ 🟢 CLS 0.08 │ │ • ReferenceError: foo i... │ │
|
||||
│ │ │ │ │ │
|
||||
│ └──────────────────────────┘ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ 实时错误流(最近 5 分钟) │ │
|
||||
│ │ 14:32:15 TypeError: Cannot read property 'foo' │ │
|
||||
│ │ 14:32:10 AxiosError: Request failed with 500 │ │
|
||||
│ │ 14:32:05 ReferenceError: x is not defined │ │
|
||||
│ │ ... │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 5 个核心指标卡片放在最上面,一眼看完
|
||||
- 指标卡片底部的小数字是「同比昨日」,红涨绿跌
|
||||
- 左图右表,趋势 + Top 排行
|
||||
- 底部实时流,让你感知系统状态
|
||||
|
||||
### 4.2 错误列表页(Errors)
|
||||
|
||||
**定位**:浏览和管理错误 Issue,找到要处理的问题
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 错误 Issues [24h ▼] [🔍 搜索错误消息] │
|
||||
│ 128 个错误 · 12 个未解决 · 3 个今日新增 [状态: 全部 ▼] [级别: ▼] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───┬─────────────────────┬──────┬───────┬───────┬──────────┐ │
|
||||
│ │ ✓ │ 错误消息 │ 级别 │ 次数 │ 用户 │ 最后出现 │ │
|
||||
│ ├───┼─────────────────────┼──────┼───────┼───────┼──────────┤ │
|
||||
│ │ 🔴│Cannot read property│error │ 456 │ 123 │ 2 分钟前 │ │
|
||||
│ │ │ 'foo' of undefined│ │ │ │ │ │
|
||||
│ ├───┼─────────────────────┼──────┼───────┼───────┼──────────┤ │
|
||||
│ │ 🔴│AxiosError: Request │error │ 234 │ 89 │ 5 分钟前 │ │
|
||||
│ │ │ failed with 500 │ │ │ │ │ │
|
||||
│ ├───┼─────────────────────┼──────┼───────┼───────┼──────────┤ │
|
||||
│ │ 🟡│ReferenceError: x is│warning│ 123 │ 45 │ 12 分钟前 │ │
|
||||
│ │ │ not defined │ │ │ │ │ │
|
||||
│ └───┴─────────────────────┴──────┴───────┴───────┴──────────┘ │
|
||||
│ │
|
||||
│ ◀ 1 2 3 4 ... 12 ▶ 每页 20 条 ▼ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**筛选器**:
|
||||
- 时间范围(15m / 1h / 6h / 24h / 7d / 30d / 自定义)
|
||||
- 状态(全部 / 未解决 / 已解决 / 已忽略)
|
||||
- 级别(全部 / fatal / error / warning / info)
|
||||
- 搜索(错误消息、指纹、文件路径)
|
||||
- 环境
|
||||
- 版本(release)
|
||||
|
||||
**列表交互**:
|
||||
- 点击行 → 进入错误详情
|
||||
- 行前 checkbox → 批量操作(标记已解决、忽略)
|
||||
- hover 行 → 显示快捷操作(查看、已解决、忽略)
|
||||
|
||||
### 4.3 错误详情页
|
||||
|
||||
**定位**:深入分析单个错误,定位根因
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ← 返回列表 [标记已解决] [忽略] [分配给 ▼] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 🔴 TypeError: Cannot read property 'foo' of undefined │
|
||||
│ Uncaught exception · active · 来自浏览器 JS │
|
||||
│ │
|
||||
│ 📊 概览 📝 堆栈 📋 Breadcrumbs 👥 用户 🌐 分布 ⏱️ 趋势 │
|
||||
│ ───── │
|
||||
│ │
|
||||
│ ┌──────────────┬──────────────┬──────────────┬──────────────┐ │
|
||||
│ │ 总次数 │ 影响用户 │ 首次出现 │ 最后出现 │ │
|
||||
│ │ 456 │ 123 │ 3 天前 │ 2 分钟前 │ │
|
||||
│ └──────────────┴──────────────┴──────────────┴──────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ 发生趋势(7 天) │ │
|
||||
│ │ ╱╲ │ │
|
||||
│ │ ╱ ╲ ╱╲ │ │
|
||||
│ │ ╱╲╱ ╲╱ ╲ │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ 堆栈跟踪 │ │
|
||||
│ │ 📄 app.js:123 onClick │ │
|
||||
│ │ 📄 utils.js:45 handleClick │ │
|
||||
│ │ 📄 index.js:8 main │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ 最近出现(最新 5 条) │ │
|
||||
│ │ 14:32:15 user_123 Chrome 120 macOS 点击按钮时 │ │
|
||||
│ │ 14:31:45 user_456 Safari 17 iOS 加载页面时 │ │
|
||||
│ │ ... │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Tab 内容**:
|
||||
- **概览**:核心数据 + 趋势图 + 最近出现
|
||||
- **堆栈**:完整堆栈跟踪,可折叠,点击跳源码(如果配置了 sourcemap)
|
||||
- **Breadcrumbs**:用户操作路径,时间线展示
|
||||
- **用户**:受影响的用户列表
|
||||
- **分布**:浏览器、OS、设备、地区分布
|
||||
- **趋势**:更长时间的趋势图
|
||||
|
||||
### 4.4 性能分析页
|
||||
|
||||
**定位**:看页面性能好不好,哪里慢
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 性能分析 [24h ▼] [页面: 全部 ▼] │
|
||||
│ Web Vitals 指标 [浏览器: 全部 ▼] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
|
||||
│ │ LCP │ │ FID │ │ CLS │ │ FCP │ │
|
||||
│ │ 3.2s 🔴 │ │ 120ms 🟡 │ │ 0.15 🟢 │ │ 1.8s 🟡 │ │
|
||||
│ │ P95: 4.5s │ │ P95: 200ms │ │ P95: 0.25 │ │ P95: 2.5s│ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ 性能趋势(P50 / P75 / P95 / P99) │ │
|
||||
│ │ ╭────────────────────────────────────────────────────╮ │ │
|
||||
│ │ │ ▲ P99 ── P95 ─ ─ P75 ┄ ┄ P50 │ │ │
|
||||
│ │ │ │╲ │ │ │
|
||||
│ │ │ │ ╲──────╮ │ │ │
|
||||
│ │ │ │ ╲╱╲ │ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ │ ╰───────────────────────────────────────────────╯ │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ 页面性能排行 [按 LCP 排序 ▼] │ │
|
||||
│ ├─────────────────────┬──────┬──────┬──────┬──────┬───────┤ │
|
||||
│ │ 页面 │ LCP │ FID │ CLS │ 样本 │ 评级 │ │
|
||||
│ ├─────────────────────┼──────┼──────┼──────┼──────┼───────┤ │
|
||||
│ │ /home │ 2.8s │ 80ms │ 0.10 │ 1.2k │ 🟢 良 │ │
|
||||
│ │ /product/:id │ 4.2s │ 150ms│ 0.20 │ 800 │ 🔴 差 │ │
|
||||
│ │ /checkout │ 3.5s │ 120ms│ 0.15 │ 500 │ 🟡 中 │ │
|
||||
│ │ ... │ │ │ │ │ │ │
|
||||
│ └─────────────────────┴──────┴──────┴──────┴──────┴───────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.5 告警中心
|
||||
|
||||
**定位**:配置告警规则,查看告警历史
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 告警中心 │
|
||||
│ ┌─────────┐ │
|
||||
│ │ 告警规则 │ 告警历史 │
|
||||
│ └─────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [+ 新建告警规则] │
|
||||
│ │
|
||||
│ ┌───┬─────────────────────┬──────────┬───────┬────────┬─────┐ │
|
||||
│ │ ✓ │ 规则名称 │ 类型 │ 级别 │ 状态 │ 操作 │ │
|
||||
│ ├───┼─────────────────────┼──────────┼───────┼────────┼─────┤ │
|
||||
│ │ 🔔│ 错误数量突增 │ 突增告警 │ 严重 │ ✅ 启用 │ ... │ │
|
||||
│ ├───┼─────────────────────┼──────────┼───────┼────────┼─────┤ │
|
||||
│ │ 🔔│ 新错误出现 │ 新错误 │ 警告 │ ✅ 启用 │ ... │ │
|
||||
│ ├───┼─────────────────────┼──────────┼───────┼────────┼─────┤ │
|
||||
│ │ 🔔│ LCP 超过 4s │ 阈值告警 │ 警告 │ ✅ 启用 │ ... │ │
|
||||
│ └───┴─────────────────────┴──────────┴───────┴────────┴─────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、组件设计
|
||||
|
||||
### 5.1 指标卡片
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ 错误总数 │ ← 标题(小字、灰色)
|
||||
│ 128 │ ← 数值(大字、粗体)
|
||||
│ ↑ 15% vs 昨日 │ ← 趋势(红涨绿跌)
|
||||
│ ╱╲ │ ← 迷你趋势图(可选)
|
||||
│ ╱ ╲ │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
**变体**:
|
||||
- 标准:数字 + 趋势
|
||||
- 带评级:数字 + 颜色评级(性能指标用)
|
||||
- 极简:只有数字
|
||||
|
||||
### 5.2 状态标签
|
||||
|
||||
| 状态 | 颜色 | 示例 |
|
||||
|------|------|------|
|
||||
| Active(未解决) | 红底白字 | `active` |
|
||||
| Resolved(已解决) | 绿底白字 | `resolved` |
|
||||
| Ignored(已忽略) | 灰底灰字 | `ignored` |
|
||||
| Firing(告警中) | 红底白字 | `FIRING` |
|
||||
| Resolved(已恢复) | 绿底白字 | `RESOLVED` |
|
||||
|
||||
### 5.3 错误级别标记
|
||||
|
||||
| 级别 | 颜色 |
|
||||
|------|------|
|
||||
| Fatal | 🔴 深红 |
|
||||
| Error | 🔴 红 |
|
||||
| Warning | 🟡 黄 |
|
||||
| Info | 🔵 蓝 |
|
||||
| Debug | ⚪ 灰 |
|
||||
|
||||
### 5.4 性能评级
|
||||
|
||||
| 评级 | 颜色 | LCP | FID | CLS |
|
||||
|------|------|-----|-----|-----|
|
||||
| Good 🟢 | 绿 | < 2.5s | < 100ms | < 0.1 |
|
||||
| Needs Improvement 🟡 | 黄 | 2.5-4s | 100-300ms | 0.1-0.25 |
|
||||
| Poor 🔴 | 红 | > 4s | > 300ms | > 0.25 |
|
||||
|
||||
---
|
||||
|
||||
## 六、图表设计
|
||||
|
||||
### 6.1 图表库选型
|
||||
|
||||
| 库 | 体积 | 功能 | 适合场景 |
|
||||
|----|------|------|----------|
|
||||
| Chart.js | ~60KB gzip | 基础图表够用 | 轻量、简单 |
|
||||
| ECharts | ~150KB gzip | 功能强大 | 复杂图表、交互多 |
|
||||
| uPlot | ~15KB gzip | 时序图表 | 极致轻量、性能好 |
|
||||
|
||||
**推荐方案**:
|
||||
- 默认用 Chart.js(够用、轻量、生态好)
|
||||
- 性能监控页面用 ECharts(需要更复杂的交互)
|
||||
- 可按需加载,首屏只加载必要的
|
||||
|
||||
### 6.2 图表类型
|
||||
|
||||
| 图表 | 用途 | 页面 |
|
||||
|------|------|------|
|
||||
| 折线图 | 错误趋势、性能趋势 | 概览、错误、性能 |
|
||||
| 柱状图 | Top N 排行、分布 | 错误、性能 |
|
||||
| 饼图 / 环形图 | 占比、分布 | 概览、错误 |
|
||||
| 面积图 | 堆叠趋势 | 性能 |
|
||||
| 热力图 | 时间段分布 | 错误 |
|
||||
|
||||
### 6.3 与 Grafana 的关系
|
||||
|
||||
**管理后台解决的问题**:
|
||||
- 错误 Issue 管理(状态、分配、标记)
|
||||
- 项目配置和管理
|
||||
- 告警规则配置
|
||||
- 更友好的错误详情展示
|
||||
|
||||
**Grafana 解决的问题**:
|
||||
- 灵活的自定义仪表盘
|
||||
- 复杂的数据探索
|
||||
- 多种数据源联合查询
|
||||
- 告警(可复用 Grafana Alerting)
|
||||
|
||||
**分工原则**:
|
||||
- 常用功能做进管理后台(开箱即用)
|
||||
- 高级分析用 Grafana(灵活强大)
|
||||
- 管理后台提供一键跳转到 Grafana 的链接
|
||||
|
||||
---
|
||||
|
||||
## 七、前端架构
|
||||
|
||||
### 7.1 目录结构
|
||||
|
||||
```
|
||||
frontend/ # 前端工程(独立目录)
|
||||
├── public/
|
||||
│ └── favicon.ico
|
||||
├── src/
|
||||
│ ├── assets/ # 静态资源
|
||||
│ │ ├── images/
|
||||
│ │ └── icons/
|
||||
│ ├── components/ # 通用组件
|
||||
│ │ ├── layout/ # 布局组件
|
||||
│ │ │ ├── MainLayout.tsx # 主布局(侧边栏+顶栏+内容)
|
||||
│ │ │ ├── Sidebar.tsx # 侧边栏
|
||||
│ │ │ ├── Header.tsx # 顶部栏
|
||||
│ │ │ └── PageHeader.tsx # 页面头
|
||||
│ │ ├── chart/ # 图表组件
|
||||
│ │ │ ├── LineChart.tsx # 折线图
|
||||
│ │ │ ├── BarChart.tsx # 柱状图
|
||||
│ │ │ └── PieChart.tsx # 饼图
|
||||
│ │ ├── metrics/ # 指标组件
|
||||
│ │ │ └── MetricCard.tsx # 指标卡片
|
||||
│ │ └── common/ # 其他通用组件
|
||||
│ │ ├── StatusTag.tsx # 状态标签
|
||||
│ │ └── CopyButton.tsx # 复制按钮
|
||||
│ ├── pages/ # 页面
|
||||
│ │ ├── overview/ # 概览页
|
||||
│ │ │ └── index.tsx
|
||||
│ │ ├── errors/ # 错误管理
|
||||
│ │ │ ├── List.tsx # 错误列表
|
||||
│ │ │ └── Detail.tsx # 错误详情
|
||||
│ │ ├── performance/ # 性能分析
|
||||
│ │ │ └── index.tsx
|
||||
│ │ ├── network/ # 网络分析
|
||||
│ │ │ └── index.tsx
|
||||
│ │ ├── alerts/ # 告警中心
|
||||
│ │ │ ├── Rules.tsx # 告警规则
|
||||
│ │ │ └── History.tsx # 告警历史
|
||||
│ │ ├── logs/ # 日志查询
|
||||
│ │ │ └── index.tsx
|
||||
│ │ ├── settings/ # 项目设置
|
||||
│ │ │ └── index.tsx
|
||||
│ │ ├── projects/ # 项目管理
|
||||
│ │ │ └── List.tsx
|
||||
│ │ └── integration/ # 接入指南
|
||||
│ │ └── index.tsx
|
||||
│ ├── store/ # 状态管理 (Zustand)
|
||||
│ │ ├── index.ts # 导出
|
||||
│ │ ├── useAppStore.ts # 全局状态(主题、侧边栏等)
|
||||
│ │ └── useProjectStore.ts # 当前项目状态
|
||||
│ ├── services/ # API 服务
|
||||
│ │ ├── request.ts # Axios 封装
|
||||
│ │ ├── project.ts # 项目相关 API
|
||||
│ │ ├── error.ts # 错误相关 API
|
||||
│ │ ├── performance.ts # 性能相关 API
|
||||
│ │ └── alert.ts # 告警相关 API
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ │ ├── useProject.ts # 当前项目 hook
|
||||
│ │ ├── useTimeRange.ts # 时间范围 hook
|
||||
│ │ └── useChartTheme.ts # 图表主题 hook
|
||||
│ ├── router/ # 路由配置
|
||||
│ │ ├── index.tsx # 路由入口
|
||||
│ │ └── routes.ts # 路由表
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── format.ts # 格式化(时间、数字等)
|
||||
│ │ ├── date.ts # 日期工具
|
||||
│ │ └── storage.ts # 本地存储
|
||||
│ ├── types/ # TypeScript 类型定义
|
||||
│ │ ├── api.ts # API 类型
|
||||
│ │ ├── error.ts # 错误相关类型
|
||||
│ │ ├── performance.ts # 性能相关类型
|
||||
│ │ └── common.ts # 通用类型
|
||||
│ ├── styles/ # 全局样式
|
||||
│ │ ├── global.less
|
||||
│ │ └── variables.less
|
||||
│ ├── App.tsx # 根组件
|
||||
│ └── main.tsx # 入口文件
|
||||
├── .eslintrc.js # ESLint 配置
|
||||
├── .prettierrc # Prettier 配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
├── vite.config.ts # Vite 配置
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### 7.2 路由设计
|
||||
|
||||
使用 React Router v6,嵌套路由 + 懒加载:
|
||||
|
||||
```typescript
|
||||
// router/routes.ts
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/overview" replace /> },
|
||||
{ path: 'overview', lazy: () => import('@/pages/overview') },
|
||||
{
|
||||
path: 'errors',
|
||||
children: [
|
||||
{ index: true, lazy: () => import('@/pages/errors/List') },
|
||||
{ path: ':id', lazy: () => import('@/pages/errors/Detail') },
|
||||
],
|
||||
},
|
||||
{ path: 'performance', lazy: () => import('@/pages/performance') },
|
||||
{ path: 'network', lazy: () => import('@/pages/network') },
|
||||
{
|
||||
path: 'alerts',
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="rules" replace /> },
|
||||
{ path: 'rules', lazy: () => import('@/pages/alerts/Rules') },
|
||||
{ path: 'history', lazy: () => import('@/pages/alerts/History') },
|
||||
],
|
||||
},
|
||||
{ path: 'logs', lazy: () => import('@/pages/logs') },
|
||||
{ path: 'settings', lazy: () => import('@/pages/settings') },
|
||||
{ path: 'integration', lazy: () => import('@/pages/integration') },
|
||||
],
|
||||
},
|
||||
{ path: '/projects', element: <ProjectList /> },
|
||||
{ path: '*', element: <NotFound /> },
|
||||
];
|
||||
```
|
||||
|
||||
### 7.3 状态管理(Zustand)
|
||||
|
||||
**为什么用 Zustand 而不是 Redux?**
|
||||
- 比 Redux 简单太多,没有 reducer、action、dispatch 那些概念
|
||||
- 体积小(~1KB),性能好
|
||||
- TypeScript 支持好
|
||||
- 支持 middleware(persist、devtools 等)
|
||||
|
||||
```typescript
|
||||
// store/useAppStore.ts
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface AppState {
|
||||
theme: 'dark' | 'light';
|
||||
sidebarCollapsed: boolean;
|
||||
currentProject: Project | null;
|
||||
|
||||
toggleTheme: () => void;
|
||||
toggleSidebar: () => void;
|
||||
setCurrentProject: (project: Project) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
theme: 'dark',
|
||||
sidebarCollapsed: false,
|
||||
currentProject: null,
|
||||
|
||||
toggleTheme: () => set((s) => ({ theme: s.theme === 'dark' ? 'light' : 'dark' })),
|
||||
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||||
setCurrentProject: (project) => set({ currentProject: project }),
|
||||
}));
|
||||
```
|
||||
|
||||
### 7.4 数据请求(TanStack Query)
|
||||
|
||||
**为什么用 TanStack Query?**
|
||||
- 自动缓存,相同请求不会重复发
|
||||
- 后台自动刷新
|
||||
- 分页、无限滚动都有封装
|
||||
- 乐观更新、重试策略
|
||||
- 和 Zustand 互补,不用把 API 数据放全局 store
|
||||
|
||||
```typescript
|
||||
// hooks/useErrors.ts
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getErrorList } from '@/services/error';
|
||||
|
||||
export function useErrorList(params: ErrorListParams) {
|
||||
return useQuery({
|
||||
queryKey: ['errors', params],
|
||||
queryFn: () => getErrorList(params),
|
||||
keepPreviousData: true,
|
||||
staleTime: 30_000, // 30秒内认为是新鲜的
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 7.5 请求封装(Axios)
|
||||
|
||||
```typescript
|
||||
// services/request.ts
|
||||
import axios from 'axios';
|
||||
import { message } from 'antd';
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// 请求拦截器:加 token
|
||||
request.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器:统一处理错误
|
||||
request.interceptors.response.use(
|
||||
(res) => res.data,
|
||||
(err) => {
|
||||
const status = err.response?.status;
|
||||
const message = err.response?.data?.message || err.message;
|
||||
|
||||
if (status === 401) {
|
||||
// 登录过期,跳登录页
|
||||
} else {
|
||||
message.error(message);
|
||||
}
|
||||
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export default request;
|
||||
```
|
||||
|
||||
### 7.6 主题配置(Ant Design 5 深色主题)
|
||||
|
||||
Ant Design 5 用 CSS-in-JS,通过 ConfigProvider 配置主题:
|
||||
|
||||
```typescript
|
||||
// App.tsx
|
||||
import { ConfigProvider, theme as antdTheme } from 'antd';
|
||||
import { useAppStore } from '@/store';
|
||||
|
||||
function App() {
|
||||
const { theme } = useAppStore();
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme === 'dark'
|
||||
? antdTheme.darkAlgorithm
|
||||
: antdTheme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#0090f9',
|
||||
borderRadius: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Router />
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**自定义暗色主题色板**(贴合 GitHub Dark 风格):
|
||||
```typescript
|
||||
{
|
||||
colorBgLayout: '#0d1117', // 页面背景
|
||||
colorBgContainer: '#161b22', // 卡片背景
|
||||
colorBgElevated: '#21262d', // 悬浮背景
|
||||
colorBorder: '#30363d', // 边框
|
||||
colorText: '#e6edf3', // 主文字
|
||||
colorTextSecondary: '#8b949e',// 次文字
|
||||
colorTextTertiary: '#6e7681', // 弱文字
|
||||
}
|
||||
```
|
||||
|
||||
### 7.7 图表封装
|
||||
|
||||
基于 ECharts 封装通用图表组件,自动适配主题和容器大小:
|
||||
|
||||
```typescript
|
||||
// components/chart/BaseChart.tsx
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as echarts from 'echarts';
|
||||
import { useAppStore } from '@/store';
|
||||
|
||||
interface BaseChartProps {
|
||||
option: echarts.EChartsOption;
|
||||
height?: number | string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BaseChart({ option, height = 300, className }: BaseChartProps) {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||||
const { theme } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
|
||||
chartInstance.current = echarts.init(
|
||||
chartRef.current,
|
||||
theme === 'dark' ? 'dark' : null
|
||||
);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
chartInstance.current?.resize();
|
||||
});
|
||||
resizeObserver.observe(chartRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
chartInstance.current?.dispose();
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
chartInstance.current?.setOption(option, true);
|
||||
}, [option]);
|
||||
|
||||
return <div ref={chartRef} style={{ height }} className={className} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 7.8 构建与部署
|
||||
|
||||
**开发环境**:
|
||||
```bash
|
||||
pnpm dev # 启动开发服务器
|
||||
pnpm build # 生产构建
|
||||
pnpm preview # 预览构建结果
|
||||
pnpm lint # ESLint 检查
|
||||
pnpm type-check # TypeScript 类型检查
|
||||
```
|
||||
|
||||
**部署方案**:
|
||||
- 构建产物:`dist/` 目录
|
||||
- Nginx 直接托管静态文件
|
||||
- API 请求通过 Nginx 反向代理到后端
|
||||
- 和现有 `public/manage/` 共存,新前端走 `/manage/` 路径
|
||||
|
||||
```nginx
|
||||
location /manage/ {
|
||||
try_files $uri $uri/ /manage/index.html;
|
||||
root /path/to/frontend/dist;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、交互设计原则
|
||||
|
||||
### 8.1 快速导航
|
||||
|
||||
- 全局搜索:`⌘K` 唤起,可搜项目、错误、页面
|
||||
- 面包屑:永远知道自己在哪
|
||||
- 相关跳转:错误详情里可跳转到对应页面的性能分析
|
||||
|
||||
### 8.2 数据加载
|
||||
|
||||
- 骨架屏:数据加载中显示骨架,不跳
|
||||
- 增量加载:列表滚动加载
|
||||
- 实时刷新:可开/关,默认 30 秒自动刷新
|
||||
|
||||
### 8.3 操作反馈
|
||||
|
||||
- 重要操作(删除、标记已解决)有确认弹窗
|
||||
- 成功操作顶部弹 toast,3 秒自动消失
|
||||
- 操作失败显示错误原因,可重试
|
||||
|
||||
---
|
||||
|
||||
## 九、API 设计
|
||||
|
||||
### 9.1 统计 API
|
||||
|
||||
```
|
||||
# 概览数据
|
||||
GET /api/projects/{id}/overview?time_range=24h
|
||||
|
||||
# 错误趋势
|
||||
GET /api/projects/{id}/errors/trend?time_range=24h&interval=1h
|
||||
|
||||
# 错误列表
|
||||
GET /api/projects/{id}/errors?page=1&page_size=20&level=error&status=active
|
||||
|
||||
# 错误详情
|
||||
GET /api/projects/{id}/errors/{fingerprint}
|
||||
|
||||
# 性能指标
|
||||
GET /api/projects/{id}/performance/metrics?time_range=24h&metric=lcp
|
||||
|
||||
# 告警规则
|
||||
GET /api/projects/{id}/alerts/rules
|
||||
POST /api/projects/{id}/alerts/rules
|
||||
PUT /api/projects/{id}/alerts/rules/{rule_id}
|
||||
DELETE /api/projects/{id}/alerts/rules/{rule_id}
|
||||
|
||||
# 告警历史
|
||||
GET /api/projects/{id}/alerts/history?page=1&page_size=20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、演进路线
|
||||
|
||||
### 10.1 当前状态
|
||||
|
||||
现有的 [index.html](file:///Users/weidingjian/Desktop/work/ai/light-sentry/public/manage/index.html) 是项目列表页,只有最基础的 CRUD 功能。
|
||||
|
||||
### 10.2 版本规划
|
||||
|
||||
**v1.1(下一个版本)**:
|
||||
- 改造成侧边栏 + 内容区的布局
|
||||
- 新增概览页(核心指标 + 错误趋势)
|
||||
- 新增错误列表页(从聚合表读)
|
||||
- 项目选择器移到顶部
|
||||
|
||||
**v1.2**:
|
||||
- 错误详情页
|
||||
- 性能分析页
|
||||
- 告警规则管理
|
||||
|
||||
**v2.0**:
|
||||
- 完整的错误管理工作流
|
||||
- 性能深入分析(瀑布图、资源时序)
|
||||
- 用户会话追踪
|
||||
|
|
@ -0,0 +1,544 @@
|
|||
# 部署与运维设计文档
|
||||
|
||||
## 一、部署方式
|
||||
|
||||
### 1.1 Docker Compose(推荐)
|
||||
|
||||
一键部署,适合中小团队。
|
||||
|
||||
```
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
包含的服务:
|
||||
|
||||
| 服务 | 镜像 | 端口 | 说明 |
|
||||
|------|------|------|------|
|
||||
| light-sentry-api | 自建 (Node.js) | 9000 | API 服务 + 管理后台 |
|
||||
| loki | grafana/loki | 3100 | 日志存储 |
|
||||
| promtail | grafana/promtail | - | 日志收集(容器日志) |
|
||||
| grafana | grafana/grafana | 3000 | 数据可视化 |
|
||||
| dozzle | amir20/dozzle | 8080 | 容器日志查看 |
|
||||
| nginx | (系统已有) | 80/443 | 反向代理 + SSL |
|
||||
|
||||
### 1.2 二进制部署
|
||||
|
||||
适合对 Docker 不熟悉的场景:
|
||||
|
||||
1. 安装 Node.js 18+
|
||||
2. 安装 Loki(单二进制)
|
||||
3. 安装 Grafana
|
||||
4. 配置 Nginx
|
||||
5. 启动 API 服务(pm2 守护)
|
||||
|
||||
### 1.3 Kubernetes 部署
|
||||
|
||||
适合大规模、多租户场景:
|
||||
|
||||
- Helm Chart 部署
|
||||
- HPA 自动扩缩容
|
||||
- Loki 分布式模式
|
||||
- MySQL / PostgreSQL 集群
|
||||
|
||||
---
|
||||
|
||||
## 二、资源需求
|
||||
|
||||
### 2.1 最小配置(测试/个人用)
|
||||
|
||||
| 资源 | 配置 | 说明 |
|
||||
|------|------|------|
|
||||
| CPU | 1 核 | 低流量下足够 |
|
||||
| 内存 | 1GB | 紧张,可能需要调小 Loki 缓存 |
|
||||
| 磁盘 | 20GB | 存 7 天日志(每天 ~2GB) |
|
||||
| 网络 | 1Mbps | 上报流量不大 |
|
||||
|
||||
**承载能力**:
|
||||
- 10 个项目以内
|
||||
- 每天 1 万事件以内
|
||||
|
||||
### 2.2 推荐配置(小团队)
|
||||
|
||||
| 资源 | 配置 | 说明 |
|
||||
|------|------|------|
|
||||
| CPU | 2 核 | 应对突发流量 |
|
||||
| 内存 | 4GB | Loki + API + Grafana 都够 |
|
||||
| 磁盘 | 100GB SSD | 存 30 天日志 |
|
||||
| 网络 | 5Mbps | 足够 |
|
||||
|
||||
**承载能力**:
|
||||
- 50 个项目以内
|
||||
- 每天 100 万事件以内
|
||||
|
||||
### 2.3 生产配置(中大型团队)
|
||||
|
||||
| 资源 | 配置 | 说明 |
|
||||
|------|------|------|
|
||||
| CPU | 4 核以上 | 高并发上报 |
|
||||
| 内存 | 8GB 以上 | Loki 内存索引 |
|
||||
| 磁盘 | 500GB+ SSD | 大量日志存储 |
|
||||
| 网络 | 10Mbps+ | 大流量上报 |
|
||||
|
||||
**承载能力**:
|
||||
- 200 个项目以内
|
||||
- 每天 1000 万事件以内
|
||||
|
||||
---
|
||||
|
||||
## 三、Docker Compose 配置详解
|
||||
|
||||
### 3.1 完整配置说明
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# API 服务 + 管理后台
|
||||
light-sentry-api:
|
||||
build: .
|
||||
container_name: light-sentry-api
|
||||
network_mode: host
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=9000
|
||||
- LOKI_URL=http://127.0.0.1:3100
|
||||
- DATA_DIR=/app/data
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- loki
|
||||
|
||||
# Loki - 日志存储
|
||||
loki:
|
||||
image: grafana/loki:2.9.4
|
||||
container_name: light-sentry-loki
|
||||
network_mode: host
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
volumes:
|
||||
- ./loki/config:/etc/loki
|
||||
- loki-data:/loki
|
||||
restart: unless-stopped
|
||||
|
||||
# Promtail - 收集容器日志
|
||||
promtail:
|
||||
image: grafana/promtail:2.9.4
|
||||
container_name: light-sentry-promtail
|
||||
network_mode: host
|
||||
volumes:
|
||||
- ./promtail/config:/etc/promtail
|
||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- loki
|
||||
|
||||
# Grafana - 可视化
|
||||
grafana:
|
||||
image: grafana/grafana:10.2.0
|
||||
container_name: light-sentry-grafana
|
||||
network_mode: host
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=admin123
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
restart: unless-stopped
|
||||
|
||||
# Dozzle - 容器日志查看
|
||||
dozzle:
|
||||
image: amir20/dozzle:latest
|
||||
container_name: light-sentry-dozzle
|
||||
network_mode: host
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
loki-data:
|
||||
grafana-data:
|
||||
```
|
||||
|
||||
### 3.2 Loki 配置
|
||||
|
||||
`loki/config/local-config.yaml`
|
||||
|
||||
```yaml
|
||||
auth_enabled: false
|
||||
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
|
||||
ingester:
|
||||
lifecycler:
|
||||
ring:
|
||||
kvstore:
|
||||
store: inmemory
|
||||
replication_factor: 1
|
||||
chunk_idle_period: 30m
|
||||
chunk_retain_period: 1m
|
||||
max_transfer_retries: 0
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
- from: 2024-01-01
|
||||
store: boltdb-shipper
|
||||
object_store: filesystem
|
||||
schema: v12
|
||||
index:
|
||||
prefix: index_
|
||||
period: 24h
|
||||
|
||||
storage_config:
|
||||
boltdb_shipper:
|
||||
active_index_directory: /loki/index
|
||||
cache_location: /loki/index_cache
|
||||
shared_store: filesystem
|
||||
filesystem:
|
||||
directory: /loki/chunks
|
||||
|
||||
limits_config:
|
||||
retention_period: 168h # 7 天
|
||||
per_stream_rate_limit: 10MB
|
||||
ingestion_rate_mb: 50
|
||||
ingestion_burst_size_mb: 100
|
||||
max_streams_per_user: 10000
|
||||
max_chunks_per_query: 2000000
|
||||
max_query_series: 5000
|
||||
|
||||
table_manager:
|
||||
retention_deletes_enabled: true
|
||||
retention_period: 168h # 7 天
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Nginx 配置
|
||||
|
||||
### 4.1 反向代理配置
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name log.example.com;
|
||||
|
||||
# SSL 配置
|
||||
ssl_certificate /path/to/fullchain.pem;
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
|
||||
# API 服务(Sentry 上报 + 管理后台 API)
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:9000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 上报接口超时
|
||||
proxy_read_timeout 30s;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
|
||||
# CORS(SDK 上报需要)
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods GET,POST,OPTIONS always;
|
||||
add_header Access-Control-Allow-Headers Content-Type,X-Sentry-Auth always;
|
||||
if ($request_method = OPTIONS) {
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# 管理后台
|
||||
location /manage/ {
|
||||
proxy_pass http://127.0.0.1:9000/manage/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Grafana
|
||||
location /grafana/ {
|
||||
proxy_pass http://127.0.0.1:3000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Dozzle
|
||||
location /dozzle/ {
|
||||
proxy_pass http://127.0.0.1:8080/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket 支持
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# Loki(可选,直接暴露给 Grafana 用,一般不需要外部访问)
|
||||
location /loki/ {
|
||||
proxy_pass http://127.0.0.1:3100/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
deny all; # 禁止外部访问,只允许内网
|
||||
}
|
||||
|
||||
# 根路径重定向到管理后台
|
||||
location = / {
|
||||
return 301 /manage/;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 限流配置
|
||||
|
||||
```nginx
|
||||
# 定义限流 zone
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/s;
|
||||
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=200 nodelay;
|
||||
# ... 其他配置
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、CI/CD 部署
|
||||
|
||||
### 5.1 Gitea Actions 示例
|
||||
|
||||
```yaml
|
||||
name: Deploy Light-Sentry
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd ~/light-sentry
|
||||
git pull
|
||||
docker compose build light-sentry-api
|
||||
docker compose up -d
|
||||
echo "Deploy completed"
|
||||
```
|
||||
|
||||
### 5.2 智能重启(只重启变更的服务)
|
||||
|
||||
检测变更的文件,只重启相关服务,减少停机时间:
|
||||
|
||||
- `src/**` → 重启 API 服务
|
||||
- `loki/**` → 重启 Loki
|
||||
- `grafana/**` → 重启 Grafana
|
||||
- `docker-compose.yml` → 全部重启
|
||||
- `nginx/**` → reload Nginx
|
||||
|
||||
---
|
||||
|
||||
## 六、监控与告警
|
||||
|
||||
### 6.1 自身监控
|
||||
|
||||
Light-Sentry 自己也要监控自己:
|
||||
|
||||
| 监控项 | 方式 | 告警阈值 |
|
||||
|--------|------|----------|
|
||||
| API 服务存活 | 健康检查接口 + Prometheus | 502 持续 1 分钟 |
|
||||
| Loki 服务存活 | Grafana 内置监控 | 连接失败 |
|
||||
| 磁盘使用率 | Node Exporter | > 80% 告警 |
|
||||
| 内存使用率 | Node Exporter | > 85% 告警 |
|
||||
| CPU 使用率 | Node Exporter | > 90% 告警 |
|
||||
| 事件上报量 | Loki 查询 | 突增 200% |
|
||||
| 错误率 | Loki 查询 | > 5% |
|
||||
|
||||
### 6.2 健康检查
|
||||
|
||||
```
|
||||
GET /health
|
||||
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"uptime": 86400,
|
||||
"queue_size": 123,
|
||||
"loki": "connected",
|
||||
"mysql": "connected"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、备份与恢复
|
||||
|
||||
### 7.1 需要备份的数据
|
||||
|
||||
| 数据 | 位置 | 备份频率 | 保留时间 |
|
||||
|------|------|----------|----------|
|
||||
| 项目配置 | data/projects.json | 每天 | 30 天 |
|
||||
| Loki 数据 | loki-data 卷 | 每周 | 4 周 |
|
||||
| Grafana 配置 | grafana-data 卷 | 每天 | 30 天 |
|
||||
| 告警规则 | MySQL | 每天 | 永久 |
|
||||
| 错误聚合 | MySQL | 每天 | 永久 |
|
||||
|
||||
### 7.2 备份脚本
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# backup.sh - Light-Sentry 备份脚本
|
||||
|
||||
BACKUP_DIR="/backup/light-sentry"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# 创建备份目录
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# 备份配置文件
|
||||
tar czf $BACKUP_DIR/config_$DATE.tar.gz /opt/light-sentry/data/
|
||||
|
||||
# 备份 Loki 数据(可选,量大)
|
||||
# docker run --rm -v loki-data:/data -v $BACKUP_DIR:/backup \
|
||||
# alpine tar czf /backup/loki_$DATE.tar.gz -C /data .
|
||||
|
||||
# 备份 Grafana
|
||||
docker run --rm -v grafana-data:/data -v $BACKUP_DIR:/backup \
|
||||
alpine tar czf /backup/grafana_$DATE.tar.gz -C /data .
|
||||
|
||||
# 清理 30 天前的备份
|
||||
find $BACKUP_DIR -name "*.tar.gz" -mtime +30 -delete
|
||||
|
||||
echo "Backup completed: $DATE"
|
||||
```
|
||||
|
||||
### 7.3 恢复流程
|
||||
|
||||
```bash
|
||||
# 1. 停止服务
|
||||
docker compose down
|
||||
|
||||
# 2. 恢复配置
|
||||
tar xzf backup/config_20240101_000000.tar.gz -C /opt/light-sentry/
|
||||
|
||||
# 3. 恢复 Grafana
|
||||
docker run --rm -v grafana-data:/data -v /backup:/backup \
|
||||
alpine tar xzf /backup/grafana_20240101_000000.tar.gz -C /data
|
||||
|
||||
# 4. 启动服务
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、性能优化
|
||||
|
||||
### 8.1 API 服务优化
|
||||
|
||||
- **Node.js 集群模式**:利用多核 CPU
|
||||
- **内存队列调优**:根据流量调整队列大小和刷盘频率
|
||||
- **连接池**:数据库连接池、HTTP 连接池
|
||||
- **缓存**:项目配置缓存,不用每次查存储
|
||||
|
||||
### 8.2 Loki 优化
|
||||
|
||||
- **调整保留时间**:从 7 天调整到实际需要的时间
|
||||
- **降低索引基数**:减少 label 数量
|
||||
- **批量写入**:增加 chunk 大小,减少写入次数
|
||||
- **内存调优**:根据实际数据量调整内存
|
||||
|
||||
### 8.3 Nginx 优化
|
||||
|
||||
- **启用 gzip 压缩**:减少传输体积
|
||||
- **启用缓存**:静态文件缓存
|
||||
- **TCP 参数调优**:增加连接数限制
|
||||
|
||||
---
|
||||
|
||||
## 九、安全加固
|
||||
|
||||
### 9.1 网络安全
|
||||
|
||||
- 管理后台加访问密码(HTTP Basic Auth 或登录)
|
||||
- Loki 不对外暴露(只允许 Grafana 访问)
|
||||
- Dozzle 加访问密码
|
||||
- 只开放必要端口(80/443)
|
||||
|
||||
### 9.2 数据安全
|
||||
|
||||
- SDK 上报接口 CORS 限制域名(可选)
|
||||
- 项目 DSN 泄露后可重置 publicKey
|
||||
- 敏感数据自动脱敏
|
||||
- 定期备份,异地存储
|
||||
|
||||
### 9.3 依赖安全
|
||||
|
||||
- 定期更新依赖
|
||||
- 镜像扫描漏洞
|
||||
- 最小权限运行
|
||||
|
||||
---
|
||||
|
||||
## 十、常见问题排查
|
||||
|
||||
### 10.1 事件上报失败
|
||||
|
||||
1. 检查网络是否通:`curl -I https://log.example.com/api/1001/envelope/`
|
||||
2. 检查 DSN 是否正确:publicKey 和 projectId 对应
|
||||
3. 查看 API 日志:`docker logs light-sentry-api`
|
||||
4. 查看 Nginx 日志:`tail -f /var/log/nginx/access.log`
|
||||
|
||||
### 10.2 Grafana 查不到数据
|
||||
|
||||
1. 检查 Loki 数据源是否配置正确
|
||||
2. 检查时间范围是否正确
|
||||
3. 检查 label 拼写是否正确
|
||||
4. 查看 Loki 日志:`docker logs light-sentry-loki`
|
||||
|
||||
### 10.3 服务无法启动
|
||||
|
||||
1. 检查端口是否被占用:`netstat -tlnp | grep 9000`
|
||||
2. 查看容器日志:`docker logs light-sentry-api`
|
||||
3. 检查配置文件格式:JSON / YAML 语法
|
||||
4. 检查磁盘空间:`df -h`
|
||||
|
||||
---
|
||||
|
||||
## 十一、升级策略
|
||||
|
||||
### 11.1 版本升级
|
||||
|
||||
```bash
|
||||
# 拉取最新代码
|
||||
git pull
|
||||
|
||||
# 重新构建并启动
|
||||
docker compose build light-sentry-api
|
||||
docker compose up -d
|
||||
|
||||
# 验证
|
||||
curl https://log.example.com/health
|
||||
```
|
||||
|
||||
### 11.2 数据迁移
|
||||
|
||||
- 配置向后兼容,新版本能读旧版本数据
|
||||
- 启动时自动检测并执行数据迁移
|
||||
- 迁移前自动备份
|
||||
|
|
@ -0,0 +1,577 @@
|
|||
# Light-Sentry 日志分析系统设计文档
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
### 1.1 背景
|
||||
基于用户需求,需要构建一个日志分析系统,支持接收 Sentry 前后端 SDK 的上报,并在 **2核2G ECS + 阿里云OSS** 的资源限制下稳定运行。
|
||||
|
||||
### 1.2 核心目标
|
||||
- **兼容性**:完全兼容 Sentry SDK 的上报协议(DSN、事件格式)
|
||||
- **轻量级**:适配 2核2G 服务器资源
|
||||
- **低成本**:利用阿里云 OSS 进行冷存储,降低成本
|
||||
- **可视化**:提供友好的日志查询和可视化界面
|
||||
|
||||
---
|
||||
|
||||
## 2. 系统架构
|
||||
|
||||
### 2.1 整体架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 客户端 SDK 层 │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Browser SDK │ │ Node SDK │ │ Python SDK │ │
|
||||
│ │ @sentry/browser │ @sentry/node │ @sentry/python │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────┼─────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌───────────────────────┐ │
|
||||
│ │ Sentry API 网关 │ │
|
||||
│ │ /api/{project}/store │ │
|
||||
│ └───────────┬───────────┘ │
|
||||
└───────────────────────────┼─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 服务端处理层 │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ 事件处理器 │──▶│ 事件转换器 │ │
|
||||
│ │ (Event Handler)│ │ (Event Converter)│ │
|
||||
│ └──────────────────┘ └──────────┬───────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────┼───────────────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
|
||||
│ │ Loki 索引 │ │ Loki 存储 │ │ OSS 持久化 │ │
|
||||
│ │ (索引元数据) │ │ (近期日志) │ │ (历史归档) │ │
|
||||
│ └───────────────┘ └───────────────┘ └───────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 可视化查询层 │
|
||||
│ ┌───────────────┐ │
|
||||
│ │ Grafana │ │
|
||||
│ │ (查询/图表) │ │
|
||||
│ └───────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 核心组件说明
|
||||
|
||||
| 组件 | 技术选型 | 角色 | 资源占用 |
|
||||
|------|---------|------|---------|
|
||||
| **API网关** | Express/FastAPI | 接收Sentry协议上报 | ~100MB |
|
||||
| **日志存储** | Loki | 索引和近期日志存储 | ~500MB |
|
||||
| **持久化层** | 阿里云OSS | 历史日志归档 | 按需扩展 |
|
||||
| **可视化** | Grafana | 日志查询和图表展示 | ~300MB |
|
||||
|
||||
**总内存预估**:~1GB(保留1GB给系统和缓冲)
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术方案
|
||||
|
||||
### 3.1 Sentry 协议兼容层
|
||||
|
||||
#### 3.1.1 协议说明
|
||||
|
||||
Sentry SDK 使用 DSN (Data Source Name) 配置上报地址:
|
||||
```
|
||||
{PROTOCOL}://{PUBLIC_KEY}@{HOST}/{PATH}/{PROJECT_ID}
|
||||
```
|
||||
|
||||
SDK 向 `/api/{PROJECT_ID}/store/` 端点发送 POST 请求,数据格式为 JSON。
|
||||
|
||||
#### 3.1.2 事件数据结构(简化版)
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "abc123...",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"level": "error",
|
||||
"logger": "javascript",
|
||||
"platform": "javascript",
|
||||
"message": "Uncaught TypeError: Cannot read property",
|
||||
"exception": {
|
||||
"values": [{
|
||||
"type": "TypeError",
|
||||
"value": "Cannot read property 'x' of undefined",
|
||||
"stacktrace": {
|
||||
"frames": [{
|
||||
"filename": "app.js",
|
||||
"lineno": 42,
|
||||
"function": "doSomething"
|
||||
}]
|
||||
}
|
||||
}]
|
||||
},
|
||||
"tags": {
|
||||
"environment": "production",
|
||||
"release": "v1.0.0"
|
||||
},
|
||||
"contexts": {
|
||||
"request": {
|
||||
"url": "https://example.com/page",
|
||||
"method": "GET"
|
||||
},
|
||||
"user": {
|
||||
"id": "12345",
|
||||
"email": "user@example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.1.3 API 网关设计
|
||||
|
||||
**技术选型**:Node.js + Express(轻量级,内存占用低)
|
||||
|
||||
**核心端点**:
|
||||
|
||||
| 端点 | 方法 | 功能 |
|
||||
|------|------|------|
|
||||
| `/api/:projectId/store/` | POST | 接收 Sentry SDK 上报事件 |
|
||||
| `/health` | GET | 健康检查 |
|
||||
|
||||
**设计要点**:
|
||||
- 支持 Sentry 的 DSN 认证(通过 `X-Sentry-Auth` 头或查询参数)
|
||||
- 异步处理,不阻塞请求响应
|
||||
- 事件格式校验和标准化
|
||||
- 支持批量上报
|
||||
|
||||
### 3.2 日志存储方案
|
||||
|
||||
#### 3.2.1 Loki 配置
|
||||
|
||||
**为什么选择 Loki**:
|
||||
- 轻量级,内存占用远低于 ELK(Elasticsearch 需要至少 4GB)
|
||||
- 基于标签索引,查询效率高
|
||||
- 与 Grafana 无缝集成
|
||||
- 支持 OSS 作为后端存储
|
||||
|
||||
**关键配置**:
|
||||
|
||||
```yaml
|
||||
auth_enabled: false
|
||||
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
|
||||
ingester:
|
||||
lifecycler:
|
||||
address: 127.0.0.1
|
||||
ring:
|
||||
kvstore:
|
||||
store: inmemory
|
||||
replication_factor: 1
|
||||
final_sleep: 0s
|
||||
chunk_idle_period: 1h
|
||||
chunk_retain_period: 30s
|
||||
max_transfer_retries: 0
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
- from: 2024-01-01
|
||||
store: boltdb-shipper
|
||||
object_store: aws
|
||||
schema: v11
|
||||
index:
|
||||
prefix: loki_index_
|
||||
period: 24h
|
||||
|
||||
storage_config:
|
||||
aws:
|
||||
s3: s3://{AK}:{SK}@{REGION}/{BUCKET}
|
||||
s3forcepathstyle: true
|
||||
boltdb_shipper:
|
||||
active_index_directory: /data/loki/index
|
||||
cache_location: /data/loki/cache
|
||||
shared_store: s3
|
||||
|
||||
limits_config:
|
||||
reject_old_samples: true
|
||||
reject_old_samples_max_age: 168h
|
||||
|
||||
chunk_store_config:
|
||||
max_look_back_period: 0s
|
||||
|
||||
table_manager:
|
||||
retention_deletes_enabled: true
|
||||
retention_period: 30d
|
||||
```
|
||||
|
||||
#### 3.2.2 OSS 存储策略
|
||||
|
||||
**存储层级**:
|
||||
|
||||
| 层级 | 用途 | 存储类型 | 成本 |
|
||||
|------|------|---------|------|
|
||||
| **标准存储** | 近期日志(30天内) | OSS标准 | 0.12元/GB/月 |
|
||||
| **低频存储** | 中期日志(30-90天) | OSS低频 | 0.07元/GB/月 |
|
||||
| **归档存储** | 历史日志(90天以上) | OSS归档 | 0.014元/GB/月 |
|
||||
|
||||
**生命周期管理**:
|
||||
```xml
|
||||
<LifecycleConfiguration>
|
||||
<Rule>
|
||||
<ID>transition-to-infrequent-access</ID>
|
||||
<Filter>
|
||||
<Prefix>loki/</Prefix>
|
||||
</Filter>
|
||||
<Status>Enabled</Status>
|
||||
<Transition>
|
||||
<Days>30</Days>
|
||||
<StorageClass>IA</StorageClass>
|
||||
</Transition>
|
||||
<Transition>
|
||||
<Days>90</Days>
|
||||
<StorageClass>Archive</StorageClass>
|
||||
</Transition>
|
||||
<Expiration>
|
||||
<Days>365</Days>
|
||||
</Expiration>
|
||||
</Rule>
|
||||
</LifecycleConfiguration>
|
||||
```
|
||||
|
||||
### 3.3 事件处理流程
|
||||
|
||||
```
|
||||
SDK上报 → API网关 → 格式校验 → 标签提取 → Loki写入 → OSS持久化
|
||||
↓
|
||||
索引元数据存储
|
||||
```
|
||||
|
||||
**标签提取策略**(用于 Loki 查询):
|
||||
|
||||
| 标签名 | 来源字段 | 用途 |
|
||||
|--------|---------|------|
|
||||
| `project` | `project_id` | 项目隔离 |
|
||||
| `environment` | `tags.environment` | 环境区分 |
|
||||
| `level` | `level` | 日志级别过滤 |
|
||||
| `platform` | `platform` | 平台分类 |
|
||||
| `release` | `tags.release` | 版本追踪 |
|
||||
| `logger` | `logger` | 日志来源 |
|
||||
|
||||
### 3.4 可视化方案
|
||||
|
||||
#### 3.4.1 Grafana 配置
|
||||
|
||||
**数据源配置**:
|
||||
- 类型:Loki
|
||||
- URL:`http://localhost:3100`
|
||||
- 认证:无
|
||||
|
||||
**查询示例**:
|
||||
|
||||
```logql
|
||||
// 查询所有 error 级别日志
|
||||
{level="error"} |= "error"
|
||||
|
||||
// 查询特定项目的日志
|
||||
{project="my-project", environment="production"}
|
||||
|
||||
// 查询包含特定错误信息的日志
|
||||
{level="error"} |= "TypeError"
|
||||
|
||||
// 统计错误数量
|
||||
count_over_time({level="error"}[1m])
|
||||
```
|
||||
|
||||
**仪表盘设计**:
|
||||
|
||||
| 面板 | 功能 | 查询 |
|
||||
|------|------|------|
|
||||
| 错误趋势 | 实时错误数量折线图 | `count_over_time({level="error"}[1m])` |
|
||||
| 环境分布 | 各环境错误占比饼图 | `sum(count_over_time({level="error"}[1h])) by (environment)` |
|
||||
| 平台分布 | 各平台错误占比饼图 | `sum(count_over_time({level="error"}[1h])) by (platform)` |
|
||||
| 最新错误 | 最近错误列表 | `{level="error"} | tail 10` |
|
||||
| Top错误类型 | 错误类型排名 | `topk(5, count by (exception_type) (count_over_time({level="error"}[1h])))` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 部署方案
|
||||
|
||||
### 4.1 服务器配置
|
||||
|
||||
**ECS 规格**:2核2G(阿里云 ecs.g6.large 或同等规格)
|
||||
|
||||
**操作系统**:Ubuntu 22.04 LTS
|
||||
|
||||
**磁盘配置**:
|
||||
- 系统盘:40GB SSD
|
||||
- 数据盘:20GB SSD(用于 Loki 索引缓存)
|
||||
|
||||
### 4.2 资源分配
|
||||
|
||||
| 组件 | CPU | 内存 |
|
||||
|------|-----|------|
|
||||
| 系统预留 | 0.5核 | 512MB |
|
||||
| API网关 (Node.js) | 0.5核 | 256MB |
|
||||
| Loki | 1核 | 768MB |
|
||||
| Grafana | 0.5核 | 512MB |
|
||||
|
||||
### 4.3 Docker Compose 部署
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
light-sentry-api:
|
||||
image: light-sentry/api:latest
|
||||
ports:
|
||||
- "9000:9000"
|
||||
environment:
|
||||
- LOKI_URL=http://loki:3100
|
||||
- OSS_ENDPOINT=${OSS_ENDPOINT}
|
||||
- OSS_ACCESS_KEY=${OSS_ACCESS_KEY}
|
||||
- OSS_SECRET_KEY=${OSS_SECRET_KEY}
|
||||
- OSS_BUCKET=${OSS_BUCKET}
|
||||
depends_on:
|
||||
- loki
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 256M
|
||||
|
||||
loki:
|
||||
image: grafana/loki:2.9.0
|
||||
ports:
|
||||
- "3100:3100"
|
||||
volumes:
|
||||
- ./loki/config:/etc/loki
|
||||
- ./loki/data:/data/loki
|
||||
environment:
|
||||
- AWS_ACCESS_KEY_ID=${OSS_ACCESS_KEY}
|
||||
- AWS_SECRET_ACCESS_KEY=${OSS_SECRET_KEY}
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 768M
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:10.2.0
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./grafana/data:/var/lib/grafana
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
|
||||
depends_on:
|
||||
- loki
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
### 4.4 Nginx 反向代理
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name logs.example.com;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:9000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
client_max_body_size 10m;
|
||||
}
|
||||
|
||||
location /grafana/ {
|
||||
proxy_pass http://localhost:3000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. SDK 接入指南
|
||||
|
||||
### 5.1 前端 SDK 配置
|
||||
|
||||
```javascript
|
||||
import * as Sentry from '@sentry/browser';
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://public_key@logs.example.com/api/1/store/',
|
||||
environment: 'production',
|
||||
release: 'v1.0.0',
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
```
|
||||
|
||||
### 5.2 后端 SDK 配置(Node.js)
|
||||
|
||||
```javascript
|
||||
import * as Sentry from '@sentry/node';
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://public_key@logs.example.com/api/2/store/',
|
||||
environment: 'production',
|
||||
release: 'v1.0.0',
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
```
|
||||
|
||||
### 5.3 后端 SDK 配置(Python)
|
||||
|
||||
```python
|
||||
import sentry_sdk
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn="https://public_key@logs.example.com/api/3/store/",
|
||||
environment="production",
|
||||
release="v1.0.0",
|
||||
traces_sample_rate=1.0,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 性能优化策略
|
||||
|
||||
### 6.1 内存优化
|
||||
|
||||
| 策略 | 说明 |
|
||||
|------|------|
|
||||
| Loki 使用 OSS 作为后端 | 减少本地磁盘和内存占用 |
|
||||
| API网关使用流处理 | 避免一次性加载大请求 |
|
||||
| 限制并发连接数 | 使用 Nginx 限流 |
|
||||
|
||||
### 6.2 存储优化
|
||||
|
||||
| 策略 | 说明 |
|
||||
|------|------|
|
||||
| OSS 生命周期管理 | 自动降级存储类型 |
|
||||
| Loki 压缩 | 启用 Snappy 压缩 |
|
||||
| 日志采样 | 高频日志采样处理 |
|
||||
|
||||
### 6.3 查询优化
|
||||
|
||||
| 策略 | 说明 |
|
||||
|------|------|
|
||||
| 标签索引 | 使用标签过滤而非全文搜索 |
|
||||
| 查询缓存 | Grafana 缓存查询结果 |
|
||||
| 时间范围限制 | 默认查询最近24小时 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 监控与告警
|
||||
|
||||
### 7.1 健康检查
|
||||
|
||||
| 检查项 | 端点 | 频率 |
|
||||
|--------|------|------|
|
||||
| API网关 | `/health` | 30秒 |
|
||||
| Loki | `/ready` | 30秒 |
|
||||
| Grafana | `/api/health` | 30秒 |
|
||||
|
||||
### 7.2 告警规则
|
||||
|
||||
| 告警项 | 条件 | 通知方式 |
|
||||
|--------|------|---------|
|
||||
| API错误率 | 错误率 > 5% | 钉钉/邮件 |
|
||||
| 服务不可用 | 健康检查失败 | 钉钉/邮件 |
|
||||
| 内存使用率 | 内存 > 85% | 钉钉/邮件 |
|
||||
| OSS存储告警 | 存储 > 80% | 阿里云告警 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 安全策略
|
||||
|
||||
### 8.1 认证授权
|
||||
|
||||
- API 网关支持 Sentry DSN 认证
|
||||
- Grafana 启用基础认证
|
||||
- Nginx 配置访问控制列表
|
||||
|
||||
### 8.2 数据加密
|
||||
|
||||
- 传输层:HTTPS/TLS 1.2+
|
||||
- 存储层:OSS 服务端加密(SSE-KMS)
|
||||
|
||||
### 8.3 访问日志
|
||||
|
||||
- Nginx 访问日志记录
|
||||
- API 网关请求日志
|
||||
|
||||
---
|
||||
|
||||
## 9. 成本预估
|
||||
|
||||
### 9.1 服务器成本
|
||||
|
||||
| 资源 | 规格 | 月费用 |
|
||||
|------|------|--------|
|
||||
| ECS | 2核2G | ~80元 |
|
||||
| 数据盘 | 20GB SSD | ~10元 |
|
||||
| **合计** | - | **~90元/月** |
|
||||
|
||||
### 9.2 OSS 存储成本
|
||||
|
||||
假设日日志量:1GB
|
||||
|
||||
| 存储类型 | 容量 | 月费用 |
|
||||
|----------|------|--------|
|
||||
| 标准存储 | 30GB | ~3.6元 |
|
||||
| 低频存储 | 60GB | ~4.2元 |
|
||||
| 归档存储 | 275GB | ~3.85元 |
|
||||
| **合计** | - | **~11.65元/月** |
|
||||
|
||||
### 9.3 总成本
|
||||
|
||||
**~100元/月**(含服务器和存储)
|
||||
|
||||
---
|
||||
|
||||
## 10. 扩展规划
|
||||
|
||||
### 10.1 短期(3个月内)
|
||||
|
||||
- 完成核心功能开发
|
||||
- 集成 Sentry 协议
|
||||
- 部署上线
|
||||
|
||||
### 10.2 中期(6个月内)
|
||||
|
||||
- 增加日志采样功能
|
||||
- 优化查询性能
|
||||
- 增加告警功能
|
||||
|
||||
### 10.3 长期(1年内)
|
||||
|
||||
- 支持更多日志来源
|
||||
- 增加分布式追踪
|
||||
- 考虑升级到更强配置
|
||||
|
||||
---
|
||||
|
||||
## 附录:组件版本建议
|
||||
|
||||
| 组件 | 推荐版本 | 备注 |
|
||||
|------|---------|------|
|
||||
| Node.js | 20.x LTS | API网关运行环境 |
|
||||
| Express | 4.x | API框架 |
|
||||
| Loki | 2.9.x | 日志存储 |
|
||||
| Grafana | 10.x | 可视化 |
|
||||
| Docker | 24.x | 容器化部署 |
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**创建日期**: 2026-06-16
|
||||
**适用场景**: 2核2G ECS + 阿里云OSS 资源受限环境
|
||||
|
|
@ -0,0 +1,658 @@
|
|||
# 业务接入指南
|
||||
|
||||
本文档说明如何将前端/后端业务接入 light-sentry 日志分析系统。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [系统架构](#1-系统架构)
|
||||
2. [接入方式概览](#2-接入方式概览)
|
||||
3. [前端接入(Browser SDK)](#3-前端接入browser-sdk)
|
||||
4. [后端接入(Node.js SDK)](#4-后端接入nodejs-sdk)
|
||||
5. [Python 接入](#5-python-接入)
|
||||
6. [容器日志接入](#6-容器日志接入)
|
||||
7. [项目与容器关联](#7-项目与容器关联)
|
||||
8. [一键部署业务机](#8-一键部署业务机)
|
||||
9. [常见问题](#9-常见问题)
|
||||
|
||||
---
|
||||
|
||||
## 1. 系统架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ log.starseekai.cn │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌────────────┐ ┌──────────────┐ │
|
||||
│ │ /manage/ │ │ /grafana/ │ │ /dozzle/ │ │
|
||||
│ │ 项目管理 │ │ 仪表盘 │ │ 容器日志查看 │ │
|
||||
│ └────┬─────┘ └─────┬──────┘ └──────┬───────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌────▼────────────────▼────────────────▼───────┐ │
|
||||
│ │ Nginx 反向代理 │ │
|
||||
│ │ /api/* → API 服务 (9000) │ │
|
||||
│ │ /loki/* → Loki (3100) │ │
|
||||
│ └────────────────┬───────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────▼───────────────────────┐ │
|
||||
│ │ Light-Sentry API 服务 │ │
|
||||
│ │ - Sentry SDK 兼容网关 │ │
|
||||
│ │ - 项目管理 API │ │
|
||||
│ │ - 一键部署 Agent │ │
|
||||
│ └────────┬──────────────┬─────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌────────▼───┐ ┌───────▼──────┐ ┌─────────────┐ │
|
||||
│ │ Loki │ │ Promtail │ │ Dozzle │ │
|
||||
│ │ 日志存储 │ │ 中心日志采集 │ │ 容器日志查看 │ │
|
||||
│ └─────────────┘ └──────────────┘ └─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
业务机部署:
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 业务 ECS 服务器 │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ Promtail │───►│ 中心 Loki │ │
|
||||
│ │ (采集容器日志) │ │ log.starseekai.cn│ │
|
||||
│ └──────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ Dozzle │───► 远程连接中心 Dozzle Agent │
|
||||
│ │ (本地容器日志) │ │
|
||||
│ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 服务入口
|
||||
|
||||
| 服务 | 地址 | 说明 |
|
||||
|------|------|------|
|
||||
| 项目管理 | `https://log.starseekai.cn/manage/` | 可视化管理项目、容器关联、一键部署 |
|
||||
| Grafana 仪表盘 | `https://log.starseekai.cn/grafana/` | 查询 Sentry 日志、容器日志、链路追踪 |
|
||||
| Dozzle 容器日志 | `https://log.starseekai.cn/dozzle/` | 实时查看业务机 Docker 容器日志 |
|
||||
| Loki 查询 API | `https://log.starseekai.cn/loki/loki/api/v1/` | Loki 原生查询 API |
|
||||
| SDK 上报地址 | `https://log.starseekai.cn/api/{projectId}/store/` | Sentry SDK 上报端点 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 接入方式概览
|
||||
|
||||
light-sentry 完全兼容 Sentry SDK 协议,只需将 SDK 的 **DSN(Data Source Name)** 指向本系统即可。
|
||||
|
||||
### 接入矩阵
|
||||
|
||||
| 场景 | 推荐 SDK | 支持协议 |
|
||||
|------|---------|---------|
|
||||
| 前端浏览器 | Sentry Browser SDK | Store API + Envelope |
|
||||
| Node.js 后端服务 | Sentry Node SDK | Store API + Envelope |
|
||||
| Python 后端服务 | Sentry Python SDK | Store API + Envelope |
|
||||
| Java Spring Boot | Sentry Java SDK | Store API + Envelope |
|
||||
| Go 服务 | Sentry Go SDK | Store API + Envelope |
|
||||
|
||||
### DSN 格式
|
||||
|
||||
Sentry SDK 要求 DSN 格式为:
|
||||
|
||||
```
|
||||
https://{public_key}@{host}/{projectId}
|
||||
```
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `public_key` | 认证公钥(可自定义,建议用项目名) | `order-frontend`、`backend-api` |
|
||||
| `host` | 本系统的入口地址 | `log.starseekai.cn` |
|
||||
| `projectId` | **必须是纯数字** | `1001`、`1002`、`1003` |
|
||||
|
||||
**重要:** Sentry SDK 要求 `projectId` 必须是纯数字,不支持字母或字符串 ID。
|
||||
|
||||
**示例 DSN:**
|
||||
|
||||
```bash
|
||||
# 前端项目
|
||||
https://order-frontend@log.starseekai.cn/1001
|
||||
|
||||
# 后端项目
|
||||
https://order-backend@log.starseekai.cn/1002
|
||||
|
||||
# Python 服务
|
||||
https://payment-service@log.starseekai.cn/1003
|
||||
```
|
||||
|
||||
### 接入流程
|
||||
|
||||
```
|
||||
1. 在项目管理页面 (https://log.starseekai.cn/manage/) 创建项目
|
||||
2. 填写纯数字项目 ID(如 1001)
|
||||
3. 填写 Public Key(可自定义,或留空自动生成)
|
||||
4. 获取生成的 DSN
|
||||
5. 在业务代码中配置 SDK
|
||||
6. 容器日志:通过一键部署或手动配置 Promtail
|
||||
7. 在 Grafana 仪表盘查看日志
|
||||
```
|
||||
|
||||
### 接入建议:前后端分开
|
||||
|
||||
**建议将前端和后端作为两个独立项目接入:**
|
||||
|
||||
```
|
||||
业务系统
|
||||
├── 前端项目 → projectId: 1001, publicKey: myapp-frontend
|
||||
└── 后端项目 → projectId: 1002, publicKey: myapp-backend
|
||||
```
|
||||
|
||||
**分开的好处:**
|
||||
|
||||
| 维度 | 说明 |
|
||||
|------|------|
|
||||
| **日志区分** | 前后端错误分开,一目了然 |
|
||||
| **采样率** | 前端可设置更高采样(用户行为重要),后端可更低(日志量大) |
|
||||
| **告警规则** | 前端错误率 > 1% 告警,后端 > 5% 告警 |
|
||||
| **链路追踪** | 通过 trace_id 仍能串联前后端日志 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端接入(Browser SDK)
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
npm install @sentry/browser
|
||||
# 或
|
||||
yarn add @sentry/browser
|
||||
# 或
|
||||
pnpm add @sentry/browser
|
||||
```
|
||||
|
||||
### 基础接入(HTML 单页)
|
||||
|
||||
```html
|
||||
<script src="https://browser.sentry-cdn.com/7.x.x/bundle.min.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
Sentry.init({
|
||||
dsn: 'https://frontend@log.starseekai.cn/1001',
|
||||
// 推荐采样率:生产环境 10%~50%
|
||||
tracesSampleRate: 0.1,
|
||||
// 生产环境关闭调试
|
||||
debug: false,
|
||||
// 环境
|
||||
environment: 'production',
|
||||
// 版本(用于按版本筛选错误)
|
||||
release: 'v1.2.3',
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### React / Vue / Next.js 接入
|
||||
|
||||
**React(Create React App / Next.js):**
|
||||
|
||||
```javascript
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import { BrowserTracing } from '@sentry/tracing';
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://frontend@log.starseekai.cn/1001',
|
||||
integrations: [new BrowserTracing()],
|
||||
tracesSampleRate: 0.1,
|
||||
environment: process.env.NODE_ENV,
|
||||
release: process.env.npm_package_version,
|
||||
});
|
||||
```
|
||||
|
||||
**Vue 3:**
|
||||
|
||||
```javascript
|
||||
import { createApp } from 'vue';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import { VueIntegration } from '@sentry/integrations';
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://frontend@log.starseekai.cn/1001',
|
||||
integrations: [new VueIntegration({ app })],
|
||||
tracesSampleRate: 0.1,
|
||||
environment: process.env.NODE_ENV,
|
||||
});
|
||||
```
|
||||
|
||||
### 手动上报错误
|
||||
|
||||
```javascript
|
||||
// 手动捕获并上报
|
||||
try {
|
||||
// 业务代码
|
||||
JSON.parse('not valid json');
|
||||
} catch (err) {
|
||||
Sentry.captureException(err, {
|
||||
// 自定义额外数据
|
||||
extra: {
|
||||
userId: currentUser.id,
|
||||
action: 'checkout',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 上报自定义消息
|
||||
Sentry.captureMessage('用户注册成功', {
|
||||
level: 'info',
|
||||
extra: { userId: 12345, plan: 'pro' },
|
||||
});
|
||||
```
|
||||
|
||||
### 添加用户上下文
|
||||
|
||||
```javascript
|
||||
// 登录时设置
|
||||
Sentry.setUser({
|
||||
id: 'user_12345',
|
||||
email: 'user@example.com',
|
||||
username: 'john_doe',
|
||||
ip_address: '{{auto}}', // 自动采集 IP
|
||||
});
|
||||
|
||||
// 登出时清除
|
||||
Sentry.setUser(null);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 后端接入(Node.js SDK)
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
npm install @sentry/node
|
||||
# 或
|
||||
yarn add @sentry/node
|
||||
```
|
||||
|
||||
### Express 接入
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const Sentry = require('@sentry/node');
|
||||
const { ExpressIntegration } = require('@sentry/integrations');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Sentry 必须在其他中间件之前初始化
|
||||
Sentry.init({
|
||||
dsn: 'https://backend@log.starseekai.cn/1002',
|
||||
integrations: [new ExpressIntegration({ app })],
|
||||
tracesSampleRate: 0.1,
|
||||
environment: process.env.NODE_ENV,
|
||||
release: process.env.npm_package_version,
|
||||
});
|
||||
|
||||
// 请求处理中间件(必须)
|
||||
app.use(Sentry.Handlers.requestHandler());
|
||||
|
||||
// 你的业务路由
|
||||
app.get('/api/order', (req, res) => {
|
||||
// 业务代码
|
||||
res.json({ orderId: 'ORD_12345' });
|
||||
});
|
||||
|
||||
// 错误处理中间件(必须)
|
||||
app.use(Sentry.Handlers.errorHandler());
|
||||
|
||||
app.listen(3000);
|
||||
```
|
||||
|
||||
### 自动捕获未处理错误和 Promise rejections
|
||||
|
||||
```javascript
|
||||
// 在 Sentry.init() 之后添加
|
||||
|
||||
// 捕获未处理的 Promise rejection
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
Sentry.captureException(reason);
|
||||
});
|
||||
|
||||
// 捕获未处理的同步异常
|
||||
process.on('uncaughtException', (err) => {
|
||||
Sentry.captureException(err);
|
||||
process.exit(1);
|
||||
});
|
||||
```
|
||||
|
||||
### 手动上报错误
|
||||
|
||||
```javascript
|
||||
const Sentry = require('@sentry/node');
|
||||
|
||||
async function createOrder(req, res) {
|
||||
try {
|
||||
const order = await db.orders.create(req.body);
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'db',
|
||||
message: 'Order created',
|
||||
data: { orderId: order.id },
|
||||
});
|
||||
res.json(order);
|
||||
} catch (err) {
|
||||
Sentry.captureException(err, {
|
||||
// 附加请求上下文
|
||||
contexts: {
|
||||
request: {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
},
|
||||
},
|
||||
});
|
||||
throw err; // 仍需抛出,让 Express 错误中间件处理
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Python 接入
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
pip install sentry-sdk
|
||||
```
|
||||
|
||||
### Django / Flask 接入
|
||||
|
||||
**Django:**
|
||||
|
||||
```python
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn='https://python-service@log.starseekai.cn/1003',
|
||||
integrations=[DjangoIntegration()],
|
||||
traces_sample_rate=0.1,
|
||||
environment='production',
|
||||
release='v1.2.3',
|
||||
)
|
||||
```
|
||||
|
||||
**Flask:**
|
||||
|
||||
```python
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn='https://python-service@log.starseekai.cn/1003',
|
||||
integrations=[FlaskIntegration()],
|
||||
traces_sample_rate=0.1,
|
||||
environment='production',
|
||||
)
|
||||
```
|
||||
|
||||
### 手动上报
|
||||
|
||||
```python
|
||||
from sentry_sdk import capture_exception, capture_message
|
||||
|
||||
try:
|
||||
1 / 0
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
||||
capture_message('用户登录成功', level='info', extra={'user_id': 12345})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 容器日志接入
|
||||
|
||||
容器日志通过 Promtail 采集,存储到 Loki,可在 Grafana 和 Dozzle 中查看。
|
||||
|
||||
### 6.1 中心 Promtail(采集本机日志)
|
||||
|
||||
适用于日志分析系统所在的 ECS 服务器。
|
||||
|
||||
编辑 `promtail/promtail-config.yml`,修改容器白名单:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- docker_sd_configs:
|
||||
- host: unix:///var/run/docker.sock
|
||||
refresh_interval: 5s
|
||||
relabel_configs:
|
||||
# 白名单:只收集以下前缀的容器(多个用 | 分隔)
|
||||
- source_labels: ['__meta_docker_container_name']
|
||||
regex: '/(light-sentry-.+|myapp-.+|nginx)'
|
||||
action: keep
|
||||
```
|
||||
|
||||
### 6.2 业务机 Promtail(一键部署)
|
||||
|
||||
通过项目管理页面的「一键部署」功能,自动将 Promtail Agent 部署到业务 ECS 服务器。
|
||||
|
||||
**部署步骤:**
|
||||
|
||||
1. 打开 `https://log.starseekai.cn/manage/`
|
||||
2. 编辑项目,填写「关联容器名称」或「容器过滤正则」
|
||||
3. 点击「一键部署」,填写业务机 SSH 信息
|
||||
4. 系统自动完成:SSH 连接 → 生成配置 → 部署 Promtail → 重启 Dozzle Agent
|
||||
|
||||
### 6.3 Dozzle 容器过滤
|
||||
|
||||
Dozzle 默认只显示 `light-sentry-*` 前缀的容器。修改 `.env` 可调整:
|
||||
|
||||
```bash
|
||||
# 格式:key=value,支持正则,多个用逗号分隔
|
||||
DOZZLE_FILTER=name=light-sentry-.+|myapp-.+|nginx
|
||||
```
|
||||
|
||||
### 6.4 Grafana 中查询容器日志
|
||||
|
||||
Promtail 采集的日志带以下标签:
|
||||
|
||||
| 标签 | 说明 |
|
||||
|------|------|
|
||||
| `host` | 主机名 |
|
||||
| `source` | `container` 标识为容器日志 |
|
||||
| `container_name` | Docker 容器名称 |
|
||||
| `container_image` | Docker 镜像名称 |
|
||||
| `composed_project` | Docker Compose 项目名 |
|
||||
| `docker_service` | Docker Compose 服务名 |
|
||||
|
||||
**查询示例:**
|
||||
|
||||
```logql
|
||||
# 按容器名查询
|
||||
{container_name="myapp-web"}
|
||||
|
||||
# 按镜像名查询
|
||||
{container_image=~"nginx.*"}
|
||||
|
||||
# 组合 Sentry 项目 + 容器日志
|
||||
{container_name=~"myapp-.*", host="prod-ecs"}
|
||||
|
||||
# 查看该容器最近 1 小时的日志
|
||||
{container_name="myapp-web"} |= "ERROR" | __error__!="JSONErr"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 项目与容器关联
|
||||
|
||||
### 7.1 关联步骤
|
||||
|
||||
1. 打开项目管理页面 `https://log.starseekai.cn/manage/`
|
||||
2. 编辑项目,填写容器信息:
|
||||
- **关联容器名称**:精确匹配容器名,多个用逗号分隔
|
||||
- **容器过滤正则**(可选):支持正则表达式,优先级更高
|
||||
|
||||
| 关联方式 | 示例 | 说明 |
|
||||
|---------|------|------|
|
||||
| 单容器 | `myapp-web` | 只采集指定容器 |
|
||||
| 多容器 | `myapp-web, myapp-api` | 逗号分隔 |
|
||||
| 正则匹配 | `myapp-.*` | 匹配所有 myapp- 开头的容器 |
|
||||
|
||||
### 7.2 接入声明
|
||||
|
||||
项目管理页面提供一键复制功能,包含:
|
||||
|
||||
- SDK 接入代码片段
|
||||
- 完整的 DSN 地址
|
||||
- Grafana 仪表盘跳转链接
|
||||
- Dozzle 容器日志跳转链接
|
||||
|
||||
### 7.3 项目管理 API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/projects/` | 列出所有项目 |
|
||||
| `GET` | `/api/projects/:id` | 获取单个项目详情 |
|
||||
| `POST` | `/api/projects/` | 创建新项目 |
|
||||
| `PUT` | `/api/projects/:id` | 更新项目信息 |
|
||||
| `DELETE` | `/api/projects/:id` | 删除项目 |
|
||||
|
||||
**创建项目示例:**
|
||||
|
||||
```bash
|
||||
curl -X POST https://log.starseekai.cn/api/projects/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "order-service",
|
||||
"name": "订单服务",
|
||||
"platform": "node",
|
||||
"environment": "production",
|
||||
"description": "处理订单创建和支付",
|
||||
"containerName": "order-service-web, order-service-worker"
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 一键部署业务机
|
||||
|
||||
通过项目管理页面的「一键部署」功能,将 Promtail Agent 和 Dozzle Agent 自动部署到业务 ECS 服务器。
|
||||
|
||||
### 8.1 部署流程
|
||||
|
||||
```
|
||||
1. 用户在页面填写业务机 SSH 信息(IP、用户名、密码)
|
||||
2. 后端启动异步部署任务
|
||||
3. SSH 连接业务机
|
||||
4. 创建部署目录、生成 Promtail/Dozzle 配置
|
||||
5. SCP 上传配置文件和 docker-compose.yml
|
||||
6. 启动容器
|
||||
7. 同步 Agent 信息到中心 Dozzle
|
||||
8. 实时 SSE 推送部署日志到前端
|
||||
```
|
||||
|
||||
### 8.2 部署配置说明
|
||||
|
||||
**Loki Push 地址**:由项目管理页面的「中心 Loki 地址」配置,默认为 `https://log.starseekai.cn/loki/loki/api/v1/push`
|
||||
|
||||
**部署目录**:默认 `/opt/light-sentry-agent`,可在部署时自定义
|
||||
|
||||
**容器过滤**:根据项目关联的容器名自动生成 Promtail 过滤正则
|
||||
|
||||
### 8.3 部署目录结构
|
||||
|
||||
```
|
||||
/opt/light-sentry-agent/
|
||||
├── docker-compose.yml # Dozzle Agent
|
||||
├── promtail/
|
||||
│ └── promtail-config.yml # Promtail 配置
|
||||
└── .env # 环境变量(DOZZLE_REMOTE_AGENT)
|
||||
```
|
||||
|
||||
### 8.4 查看部署历史
|
||||
|
||||
部署历史可在项目管理页面查看,包含:
|
||||
|
||||
- 部署时间、目标主机
|
||||
- 部署状态(成功/失败)
|
||||
- 实时部署日志
|
||||
- 容器过滤配置
|
||||
|
||||
---
|
||||
|
||||
## 9. 常见问题
|
||||
|
||||
### Q: SDK 上报后看不到日志
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 检查浏览器控制台是否有 Sentry 报错
|
||||
2. 确认 DSN 是否正确:`https://{publicKey}@log.starseekai.cn/{projectId}`
|
||||
3. 确认 `projectId` 是否是纯数字(如 `1001`)
|
||||
4. 在 Grafana Explore 中直接查询原始日志:
|
||||
|
||||
```logql
|
||||
{platform="javascript"}
|
||||
```
|
||||
|
||||
### Q: 容器日志没有采集到
|
||||
|
||||
**排查步骤:**
|
||||
|
||||
1. 确认 Promtail 容器是否在运行:`docker ps | grep promtail`
|
||||
2. 检查 Promtail 日志:`docker logs light-sentry-promtail`
|
||||
3. 确认容器名是否在白名单中
|
||||
4. 检查 Loki 是否收到数据:`curl http://localhost:3100/loki/api/v1/label/container_name/values`
|
||||
|
||||
### Q: 一键部署失败
|
||||
|
||||
**常见原因:**
|
||||
|
||||
1. SSH 密码错误或 IP 无法连接
|
||||
2. 目标目录权限不足(确保 SSH 用户有 `/opt` 写权限)
|
||||
3. Docker 未安装或未启动
|
||||
|
||||
**解决方案:**
|
||||
|
||||
- 手动检查 SSH 连接:`ssh user@host`
|
||||
- 检查目标目录权限:`ls -la /opt/`
|
||||
- 查看部署日志中的详细错误信息
|
||||
|
||||
### Q: 生产环境建议采样率是多少?
|
||||
|
||||
| 环境 | tracesSampleRate | 说明 |
|
||||
|------|-----------------|------|
|
||||
| 开发/测试 | `1.0` | 全量采集,方便调试 |
|
||||
| 预发/灰度 | `0.5` | 采集一半 |
|
||||
| 生产 | `0.05 ~ 0.2` | 5%~20%,平衡性能和数据量 |
|
||||
|
||||
### Q: 如何在 Grafana 中切换项目查看?
|
||||
|
||||
在 Grafana 仪表盘顶部有 **项目** 下拉框,选择后可按项目过滤日志。仪表盘地址格式:
|
||||
|
||||
```
|
||||
https://log.starseekai.cn/grafana/d/light-sentry-logs?var-project=1
|
||||
```
|
||||
|
||||
其中 `var-project` 参数值为项目 ID。
|
||||
|
||||
### Q: 日志量会不会太大?
|
||||
|
||||
假设:
|
||||
- 每小时 10000 次 API 请求
|
||||
- tracesSampleRate = 0.1(10%)
|
||||
- 每条 trace 上报 3~5 条日志
|
||||
- 每条日志约 2KB
|
||||
|
||||
```
|
||||
月流量 ≈ 10000 × 0.1 × 4 × 2KB × 24h × 30d ≈ 57 MB
|
||||
```
|
||||
|
||||
非常轻量,2核2G 完全没压力。
|
||||
|
||||
### Q: 如何添加自定义标签?
|
||||
|
||||
```javascript
|
||||
// 设置全局标签(所有事件都会带)
|
||||
Sentry.setTag('server', 'production');
|
||||
Sentry.setTag('region', 'cn-hangzhou');
|
||||
|
||||
// 设置用户级别标签
|
||||
Sentry.setContext('order', {
|
||||
orderId: 'ORD_12345',
|
||||
amount: 99.9,
|
||||
currency: 'CNY',
|
||||
});
|
||||
```
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import type { LightConfig, LightClient, DSNInfo, SentryEvent, EventLevel, UserInfo, Breadcrumb, ErrorEvent } from '../types';
|
||||
import type { LightConfig, LightClient, LightPlugin, DSNInfo, SentryEvent, EventLevel, UserInfo, Breadcrumb, ErrorEvent } from '../types';
|
||||
import { parseDSN } from '../utils/dsn';
|
||||
import { now } from '../utils/helper';
|
||||
import { computeFingerprint } from '../utils/hash';
|
||||
import { parseStackTrace } from '../utils/stacktrace';
|
||||
import { ConfigManager } from './ConfigManager';
|
||||
import { EventBus } from './EventBus';
|
||||
import { EventQueue } from './EventQueue';
|
||||
|
|
@ -9,7 +10,6 @@ import { Reporter } from './Reporter';
|
|||
import { PluginManager } from './PluginManager';
|
||||
|
||||
class Client implements LightClient {
|
||||
config: LightConfig;
|
||||
dsn: DSNInfo;
|
||||
|
||||
private configManager: ConfigManager;
|
||||
|
|
@ -18,9 +18,16 @@ class Client implements LightClient {
|
|||
private reporter: Reporter;
|
||||
private pluginManager: PluginManager;
|
||||
private breadcrumbs: Breadcrumb[] = [];
|
||||
private maxBreadcrumbs = 20;
|
||||
private enabled: boolean = true;
|
||||
|
||||
private get maxBreadcrumbs(): number {
|
||||
return this.configManager.get('maxBreadcrumbs') ?? 20;
|
||||
}
|
||||
|
||||
get config(): LightConfig {
|
||||
return this.configManager.getAll();
|
||||
}
|
||||
|
||||
constructor(config: LightConfig) {
|
||||
if (!config.dsn) {
|
||||
throw new Error('DSN is required');
|
||||
|
|
@ -28,23 +35,22 @@ class Client implements LightClient {
|
|||
|
||||
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.configManager.get('maxRetries') ?? 3,
|
||||
this.configManager.get('retryDelay') ?? 1000
|
||||
);
|
||||
this.pluginManager = new PluginManager(this);
|
||||
this.queue = new EventQueue(
|
||||
config.maxQueueSize ?? 100,
|
||||
config.flushInterval ?? 5000,
|
||||
this.configManager.get('maxQueueSize') ?? 100,
|
||||
this.configManager.get('flushInterval') ?? 5000,
|
||||
this.eventBus,
|
||||
async (events) => this.flushEvents(events),
|
||||
(events) => this.syncFlushEvents(events)
|
||||
);
|
||||
|
||||
this.enabled = config.enabled !== false;
|
||||
this.enabled = this.configManager.get('enabled') !== false;
|
||||
}
|
||||
|
||||
init(): void {
|
||||
|
|
@ -65,28 +71,23 @@ class Client implements LightClient {
|
|||
|
||||
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
|
||||
isFirstInBatch = false;
|
||||
|
||||
const finalEvent = this.config.get('beforeSend')
|
||||
? this.config.get('beforeSend')!(withConfig)
|
||||
: withConfig;
|
||||
if (finalEvent) {
|
||||
finalEvents.push(finalEvent);
|
||||
}
|
||||
const beforeSend = this.configManager.get('beforeSend');
|
||||
const afterBeforeSend = beforeSend ? beforeSend(withConfig) : withConfig;
|
||||
if (!afterBeforeSend) continue;
|
||||
|
||||
finalEvents.push(afterBeforeSend);
|
||||
}
|
||||
|
||||
if (finalEvents.length === 0) return;
|
||||
|
||||
await this.reporter.report(finalEvents);
|
||||
// 批次结束后标记,后续批次不再带完整 contexts
|
||||
this.configManager.markContextReported();
|
||||
|
||||
for (const event of finalEvents) {
|
||||
|
|
@ -106,7 +107,6 @@ class Client implements LightClient {
|
|||
|
||||
if (processedEvents.length === 0) return;
|
||||
|
||||
// 批次内优化:只有第一个事件带完整环境信息
|
||||
let isFirstInBatch = true;
|
||||
const finalEvents: SentryEvent[] = [];
|
||||
|
||||
|
|
@ -114,12 +114,11 @@ class Client implements LightClient {
|
|||
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);
|
||||
}
|
||||
const beforeSend = this.configManager.get('beforeSend');
|
||||
const afterBeforeSend = beforeSend ? beforeSend(withConfig) : withConfig;
|
||||
if (!afterBeforeSend) continue;
|
||||
|
||||
finalEvents.push(afterBeforeSend);
|
||||
}
|
||||
|
||||
if (finalEvents.length === 0) return;
|
||||
|
|
@ -141,7 +140,7 @@ class Client implements LightClient {
|
|||
}
|
||||
|
||||
captureException(error: Error | unknown): void {
|
||||
if (!this.enabled || !this.configManager.shouldSample()) return;
|
||||
if (!this.enabled) return;
|
||||
|
||||
const errorEvent = this.buildErrorEvent(error);
|
||||
if (this.configManager.isIgnoredError(errorEvent.message)) return;
|
||||
|
|
@ -151,7 +150,7 @@ class Client implements LightClient {
|
|||
}
|
||||
|
||||
captureMessage(message: string, level: EventLevel = 'info'): void {
|
||||
if (!this.enabled || !this.configManager.shouldSample()) return;
|
||||
if (!this.enabled) return;
|
||||
|
||||
const event: ErrorEvent = {
|
||||
type: 'error',
|
||||
|
|
@ -167,11 +166,12 @@ class Client implements LightClient {
|
|||
}
|
||||
|
||||
captureEvent(event: Partial<SentryEvent> & { type: string }): void {
|
||||
if (!this.enabled || !this.configManager.shouldSample()) return;
|
||||
if (!this.enabled) return;
|
||||
|
||||
const fullEvent = {
|
||||
timestamp: now(),
|
||||
level: 'info' as EventLevel,
|
||||
breadcrumbs: [...this.breadcrumbs],
|
||||
...event,
|
||||
} as SentryEvent;
|
||||
|
||||
|
|
@ -182,7 +182,7 @@ class Client implements LightClient {
|
|||
const timestamp = now();
|
||||
|
||||
if (error instanceof Error) {
|
||||
const frames = this.parseStackTrace(error.stack);
|
||||
const frames = parseStackTrace(error.stack, { maxFrames: 5 });
|
||||
const fingerprint = computeFingerprint(error.name, error.message, frames);
|
||||
|
||||
return {
|
||||
|
|
@ -194,7 +194,7 @@ class Client implements LightClient {
|
|||
type: error.name,
|
||||
value: error.message,
|
||||
stacktrace: {
|
||||
frames: frames.slice(0, 5),
|
||||
frames,
|
||||
},
|
||||
},
|
||||
fingerprint,
|
||||
|
|
@ -210,40 +210,6 @@ class Client implements LightClient {
|
|||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -284,10 +250,8 @@ class Client implements LightClient {
|
|||
this.enabled = true;
|
||||
}
|
||||
|
||||
use(plugin: unknown): void {
|
||||
if (plugin && typeof plugin === 'object' && 'name' in plugin && 'setup' in plugin) {
|
||||
this.pluginManager.add(plugin as never);
|
||||
}
|
||||
use(plugin: LightPlugin): void {
|
||||
this.pluginManager.add(plugin);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { LightConfig, SentryEvent, UserInfo } from '../types';
|
||||
import type { LightConfig, SentryEvent, UserInfo, ContextLevelMap, ErrorEvent, StackFrame, Breadcrumb, EventLevel } from '../types';
|
||||
import { getContexts, getContextId, getPageUrl, getReferrer } from '../utils/env';
|
||||
import { matchPatterns } from '../utils/helper';
|
||||
|
||||
const DEFAULT_CONFIG: Partial<LightConfig> = {
|
||||
enabled: true,
|
||||
|
|
@ -57,7 +58,7 @@ export class ConfigManager {
|
|||
if (!this.config.tags) {
|
||||
this.config.tags = {};
|
||||
}
|
||||
this.config.tags[key] = value;
|
||||
(this.config.tags as Record<string, string>)[key] = value;
|
||||
}
|
||||
|
||||
setExtra(key: string, value: unknown): void {
|
||||
|
|
@ -67,21 +68,8 @@ export class ConfigManager {
|
|||
(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);
|
||||
});
|
||||
return matchPatterns(message, this.config.ignoreErrors || []);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -101,49 +89,81 @@ export class ConfigManager {
|
|||
* @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 || {}) };
|
||||
// 使用 Record 来避免联合类型的问题
|
||||
const enriched: Record<string, unknown> = { ...event };
|
||||
|
||||
// 处理 tags
|
||||
if (event.tags) {
|
||||
enriched.tags = { ...event.tags };
|
||||
}
|
||||
|
||||
if (!event.request) {
|
||||
event.request = {
|
||||
// 处理 request
|
||||
if (event.request) {
|
||||
enriched.request = { ...event.request };
|
||||
}
|
||||
|
||||
// 处理 exception(仅 error 事件有)
|
||||
if ('exception' in event && event.exception) {
|
||||
const exc = event.exception;
|
||||
enriched.exception = {
|
||||
...exc,
|
||||
stacktrace: exc.stacktrace ? {
|
||||
...exc.stacktrace,
|
||||
frames: exc.stacktrace.frames ? exc.stacktrace.frames.map((f: StackFrame) => ({ ...f })) : undefined,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 处理 breadcrumbs
|
||||
if (event.breadcrumbs) {
|
||||
enriched.breadcrumbs = event.breadcrumbs.map((b: Breadcrumb) => ({ ...b }));
|
||||
}
|
||||
|
||||
// 处理 user
|
||||
if (event.user) {
|
||||
enriched.user = { ...event.user };
|
||||
}
|
||||
|
||||
// 处理 contexts
|
||||
if (event.contexts) {
|
||||
enriched.contexts = { ...event.contexts };
|
||||
}
|
||||
|
||||
if (this.config.release) {
|
||||
enriched.release = this.config.release;
|
||||
}
|
||||
if (this.config.environment) {
|
||||
enriched.environment = this.config.environment;
|
||||
}
|
||||
if (this.config.user) {
|
||||
enriched.user = this.config.user;
|
||||
}
|
||||
if (this.config.tags) {
|
||||
enriched.tags = { ...this.config.tags, ...(event.tags as Record<string, string> || {}) };
|
||||
}
|
||||
|
||||
if (!enriched.request) {
|
||||
enriched.request = {
|
||||
url: getPageUrl(),
|
||||
referrer: getReferrer(),
|
||||
};
|
||||
} else if (!event.request.url) {
|
||||
event.request.url = getPageUrl();
|
||||
} else if (!(enriched.request as Record<string, unknown>).url) {
|
||||
(enriched.request as Record<string, unknown>).url = getPageUrl();
|
||||
}
|
||||
|
||||
// context_id 总是带上,用于服务端关联环境信息
|
||||
if (!this.contextId) {
|
||||
this.contextId = getContextId();
|
||||
}
|
||||
event.context_id = this.contextId;
|
||||
enriched.context_id = this.contextId;
|
||||
|
||||
// 应用上下文分层策略:按事件级别裁剪堆栈和 breadcrumbs
|
||||
this.applyContextLevel(event);
|
||||
const result = this.applyContextLevel(enriched);
|
||||
|
||||
// 只有需要完整环境信息时才附加 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 = {
|
||||
(result as Record<string, unknown>).contexts = this.contexts;
|
||||
(result as Record<string, unknown>).env = {
|
||||
b: this.contexts.browser?.name,
|
||||
bv: this.contexts.browser?.version,
|
||||
os: this.contexts.os?.name,
|
||||
|
|
@ -151,33 +171,55 @@ export class ConfigManager {
|
|||
};
|
||||
}
|
||||
|
||||
return event;
|
||||
return result as unknown as SentryEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用上下文分层策略:根据事件级别裁剪堆栈和 breadcrumbs
|
||||
* 注意:此方法不修改原始 event 对象,返回裁剪后的副本
|
||||
*/
|
||||
private applyContextLevel(event: SentryEvent): void {
|
||||
const contextLevel = this.config.contextLevel as Record<string, { maxStackFrames: number; maxBreadcrumbs: number }>;
|
||||
if (!contextLevel) return;
|
||||
private applyContextLevel(event: Record<string, unknown>): Record<string, unknown> {
|
||||
const contextLevel = this.config.contextLevel as ContextLevelMap | undefined;
|
||||
if (!contextLevel) return event;
|
||||
|
||||
const level = event.level || 'error';
|
||||
const level = (event.level || 'error') as EventLevel;
|
||||
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;
|
||||
// 创建浅拷贝用于裁剪
|
||||
const enriched: Record<string, unknown> = { ...event };
|
||||
|
||||
// 裁剪堆栈帧(仅 error 事件有 exception)
|
||||
if ('exception' in event && event.exception && typeof event.exception === 'object') {
|
||||
const exc = event.exception as Record<string, unknown>;
|
||||
if (exc.stacktrace && typeof exc.stacktrace === 'object') {
|
||||
const st = exc.stacktrace as Record<string, unknown>;
|
||||
if (Array.isArray(st.frames)) {
|
||||
if (levelConfig.maxStackFrames === 0) {
|
||||
// info/debug 级别不需要堆栈
|
||||
delete enriched.exception;
|
||||
} else {
|
||||
enriched.exception = {
|
||||
...exc,
|
||||
stacktrace: {
|
||||
...st,
|
||||
frames: (st.frames as StackFrame[]).slice(0, levelConfig.maxStackFrames),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 裁剪 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;
|
||||
if (event.breadcrumbs && Array.isArray(event.breadcrumbs)) {
|
||||
if (levelConfig.maxBreadcrumbs === 0) {
|
||||
// info/debug 级别不需要 breadcrumbs
|
||||
delete enriched.breadcrumbs;
|
||||
} else {
|
||||
enriched.breadcrumbs = (event.breadcrumbs as Breadcrumb[]).slice(-levelConfig.maxBreadcrumbs);
|
||||
}
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ export class EventQueue {
|
|||
private flushCallback: (events: SentryEvent[]) => Promise<void>;
|
||||
private syncFlushCallback: (events: SentryEvent[]) => void;
|
||||
private lastFlushTime: number = 0;
|
||||
private isFlushing: boolean = false;
|
||||
private onVisibilityChange?: () => void;
|
||||
private onBeforeUnload?: () => void;
|
||||
private onPagehide?: () => void;
|
||||
private dedupeMap: Map<string, DedupeEntry> = new Map();
|
||||
private pendingCounts: Map<string, { count: number; ts_start: number; ts_end: number }> = new Map();
|
||||
|
||||
|
|
@ -53,26 +57,28 @@ export class EventQueue {
|
|||
}
|
||||
|
||||
private setupVisibilityListener(): void {
|
||||
this.onVisibilityChange = () => {
|
||||
if (document.hidden && (this.queue.length > 0 || this.pendingCounts.size > 0)) {
|
||||
this.flushSync();
|
||||
}
|
||||
};
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && (this.queue.length > 0 || this.pendingCounts.size > 0)) {
|
||||
this.flushSync();
|
||||
}
|
||||
});
|
||||
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
}
|
||||
|
||||
this.onBeforeUnload = () => {
|
||||
if (this.queue.length > 0 || this.pendingCounts.size > 0) {
|
||||
this.flushSync();
|
||||
}
|
||||
};
|
||||
this.onPagehide = () => {
|
||||
if (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();
|
||||
}
|
||||
});
|
||||
window.addEventListener('beforeunload', this.onBeforeUnload);
|
||||
window.addEventListener('pagehide', this.onPagehide);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +97,11 @@ export class EventQueue {
|
|||
window.shift();
|
||||
}
|
||||
|
||||
if (window.length === 0) {
|
||||
delete this.errorRateWindow[fingerprint];
|
||||
return false;
|
||||
}
|
||||
|
||||
if (window.length > 10) {
|
||||
if (!this.paused) {
|
||||
console.warn('[LightSDK] Infinite loop detected, pausing SDK for 60s', { fingerprint, count: window.length });
|
||||
|
|
@ -115,6 +126,8 @@ export class EventQueue {
|
|||
enqueue(event: SentryEvent): void {
|
||||
if (this.paused) return;
|
||||
|
||||
this.cleanupExpiredDedupeEntries();
|
||||
|
||||
const fingerprint = this.getDedupeKey(event);
|
||||
|
||||
if (fingerprint) {
|
||||
|
|
@ -148,10 +161,6 @@ export class EventQueue {
|
|||
}
|
||||
}
|
||||
|
||||
if (this.queue.length >= this.maxSize) {
|
||||
this.flush();
|
||||
}
|
||||
|
||||
this.queue.push(event);
|
||||
this.eventBus.emit('event', event);
|
||||
|
||||
|
|
@ -160,11 +169,24 @@ export class EventQueue {
|
|||
}
|
||||
}
|
||||
|
||||
private cleanupExpiredDedupeEntries(): void {
|
||||
// 每 100 条或每 10 次清理一次,避免过于频繁
|
||||
if (this.dedupeMap.size < 100 && Math.random() > 0.1) return;
|
||||
|
||||
const currentTime = now();
|
||||
const expiryTime = 60000;
|
||||
|
||||
for (const [key, entry] of this.dedupeMap) {
|
||||
if (currentTime - entry.lastTime > expiryTime) {
|
||||
this.dedupeMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildCountEvents(): SentryEvent[] {
|
||||
const countEvents: SentryEvent[] = [];
|
||||
|
||||
for (const [fingerprint, data] of this.pendingCounts.entries()) {
|
||||
// 构建聚合计数事件
|
||||
const event: CountEvent = {
|
||||
type: 'count',
|
||||
fingerprint,
|
||||
|
|
@ -177,8 +199,6 @@ export class EventQueue {
|
|||
countEvents.push(event);
|
||||
}
|
||||
|
||||
// 清空已上报的聚合计数
|
||||
this.pendingCounts.clear();
|
||||
return countEvents;
|
||||
}
|
||||
|
||||
|
|
@ -192,6 +212,9 @@ export class EventQueue {
|
|||
|
||||
async flush(): Promise<void> {
|
||||
if (this.queue.length === 0 && this.pendingCounts.size === 0) return;
|
||||
if (this.isFlushing) return;
|
||||
|
||||
this.isFlushing = true;
|
||||
|
||||
const events = this.queue.splice(0, this.queue.length);
|
||||
const countEvents = this.buildCountEvents();
|
||||
|
|
@ -202,10 +225,13 @@ export class EventQueue {
|
|||
|
||||
try {
|
||||
await this.flushCallback(allEvents);
|
||||
this.pendingCounts.clear();
|
||||
this.eventBus.emit('reported', allEvents);
|
||||
} catch (e) {
|
||||
events.forEach(evt => this.queue.unshift(evt));
|
||||
throw e;
|
||||
} finally {
|
||||
this.isFlushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -213,6 +239,7 @@ export class EventQueue {
|
|||
if (this.queue.length === 0 && this.pendingCounts.size === 0) return;
|
||||
|
||||
const events = this.queue.splice(0, this.queue.length);
|
||||
const pendingCountsSnapshot = new Map(this.pendingCounts);
|
||||
const countEvents = this.buildCountEvents();
|
||||
const allEvents = [...events, ...countEvents];
|
||||
|
||||
|
|
@ -221,9 +248,16 @@ export class EventQueue {
|
|||
|
||||
try {
|
||||
this.syncFlushCallback(allEvents);
|
||||
this.pendingCounts.clear();
|
||||
this.eventBus.emit('reported', allEvents);
|
||||
} catch (e) {
|
||||
console.error('[LightSDK] Sync flush failed', e);
|
||||
events.forEach(evt => this.queue.unshift(evt));
|
||||
for (const [key, value] of pendingCountsSnapshot) {
|
||||
if (!this.pendingCounts.has(key)) {
|
||||
this.pendingCounts.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +274,17 @@ export class EventQueue {
|
|||
clearTimeout(this.pauseTimer);
|
||||
this.pauseTimer = null;
|
||||
}
|
||||
if (typeof document !== 'undefined' && this.onVisibilityChange) {
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
if (this.onBeforeUnload) {
|
||||
window.removeEventListener('beforeunload', this.onBeforeUnload);
|
||||
}
|
||||
if (this.onPagehide) {
|
||||
window.removeEventListener('pagehide', this.onPagehide);
|
||||
}
|
||||
}
|
||||
this.dedupeMap.clear();
|
||||
this.pendingCounts.clear();
|
||||
this.errorRateWindow = {};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { DSNInfo, SentryEvent, ErrorEvent } from '../types';
|
||||
import { getEnvelopeUrl } from '../utils/dsn';
|
||||
import { now } from '../utils/helper';
|
||||
import { now, generateEventId } from '../utils/helper';
|
||||
import { encodeFields, FIELD_ENCODE_MAP } from '../utils/field-encoder';
|
||||
|
||||
export class Reporter {
|
||||
|
|
@ -34,7 +34,8 @@ export class Reporter {
|
|||
if (retries > this.maxRetries) {
|
||||
throw e;
|
||||
}
|
||||
await this.delay(this.retryDelay * Math.pow(2, retries - 1));
|
||||
const backoff = Math.min(this.retryDelay * Math.pow(2, retries - 1), 5000);
|
||||
await this.delay(backoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -68,7 +69,7 @@ export class Reporter {
|
|||
|
||||
// 构建 header(包含公共元数据)
|
||||
const headerObj: Record<string, unknown> = {
|
||||
event_id: this.generateEventId(),
|
||||
event_id: generateEventId(),
|
||||
sent_at: new Date().toISOString(),
|
||||
meta: sharedMeta,
|
||||
};
|
||||
|
|
@ -87,16 +88,16 @@ export class Reporter {
|
|||
|
||||
for (const event of eventsWithRelativeTs) {
|
||||
// 移除已共享的字段,减少重复
|
||||
let strippedEvent = this.stripSharedFields(event, sharedMeta);
|
||||
let strippedEvent: unknown = this.stripSharedFields(event as unknown as SentryEvent, sharedMeta);
|
||||
|
||||
// 短字段编码(如果启用)
|
||||
if (this.useShortFields) {
|
||||
strippedEvent = encodeFields(strippedEvent as Record<string, unknown>) as SentryEvent;
|
||||
strippedEvent = encodeFields(strippedEvent as Record<string, unknown>);
|
||||
}
|
||||
|
||||
const itemPayload = JSON.stringify(strippedEvent);
|
||||
const itemHeader = JSON.stringify({
|
||||
type: this.getEnvelopeType(event),
|
||||
type: this.getEnvelopeType(event as unknown as SentryEvent),
|
||||
length: itemPayload.length,
|
||||
});
|
||||
items.push(itemHeader, itemPayload);
|
||||
|
|
@ -114,10 +115,10 @@ export class Reporter {
|
|||
*
|
||||
* 这样可以将时间戳从 13 位数字减少到 2-4 位数字
|
||||
*/
|
||||
private applyRelativeTimestamps(events: SentryEvent[], headerObj: Record<string, unknown>): SentryEvent[] {
|
||||
private applyRelativeTimestamps(events: SentryEvent[], headerObj: Record<string, unknown>): (SentryEvent | Record<string, unknown>)[] {
|
||||
if (events.length <= 1) return events;
|
||||
|
||||
const result: SentryEvent[] = [];
|
||||
const result: (SentryEvent | Record<string, unknown>)[] = [];
|
||||
const baseTimestamp = events[0].timestamp ? new Date(events[0].timestamp).getTime() : Date.now();
|
||||
|
||||
// 在 header 中标记使用相对时间戳
|
||||
|
|
@ -125,11 +126,11 @@ export class Reporter {
|
|||
headerObj._bt = baseTimestamp; // base timestamp
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = { ...events[i] } as Record<string, unknown>;
|
||||
const event: Record<string, unknown> = { ...events[i] };
|
||||
|
||||
if (i === 0) {
|
||||
// 第一个事件保持完整时间戳
|
||||
result.push(event as SentryEvent);
|
||||
result.push(event as unknown as SentryEvent);
|
||||
} else {
|
||||
// 后续事件用差值
|
||||
const eventTs = event.timestamp ? new Date(event.timestamp as string | number).getTime() : Date.now();
|
||||
|
|
@ -146,7 +147,7 @@ export class Reporter {
|
|||
event._dsts = startDelta; // delta start timestamp
|
||||
}
|
||||
|
||||
result.push(event as SentryEvent);
|
||||
result.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +190,7 @@ export class Reporter {
|
|||
}
|
||||
|
||||
// 提取 env(编码后的环境信息)
|
||||
const firstEnv = (firstEvent as Record<string, unknown>).env;
|
||||
const firstEnv = (firstEvent as unknown as Record<string, unknown>).env;
|
||||
if (firstEnv) {
|
||||
meta.env = firstEnv;
|
||||
}
|
||||
|
|
@ -200,8 +201,8 @@ export class Reporter {
|
|||
/**
|
||||
* 移除已共享的字段,减少重复
|
||||
*/
|
||||
private stripSharedFields(event: SentryEvent, meta: Record<string, unknown>): SentryEvent {
|
||||
const stripped = { ...event };
|
||||
private stripSharedFields(event: SentryEvent, meta: Record<string, unknown>): Record<string, unknown> {
|
||||
const stripped: Record<string, unknown> = { ...event };
|
||||
|
||||
// 移除已共享的字段
|
||||
if (meta.release && stripped.release === meta.release) {
|
||||
|
|
@ -214,7 +215,7 @@ export class Reporter {
|
|||
delete stripped.user;
|
||||
}
|
||||
if (meta.env) {
|
||||
delete (stripped as Record<string, unknown>).env;
|
||||
delete stripped.env;
|
||||
}
|
||||
|
||||
return stripped;
|
||||
|
|
@ -231,12 +232,6 @@ export class Reporter {
|
|||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
|
@ -252,6 +247,8 @@ export class Reporter {
|
|||
|
||||
if (typeof fetch === 'function') {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body,
|
||||
|
|
@ -259,7 +256,9 @@ export class Reporter {
|
|||
'Content-Type': 'application/x-sentry-envelope',
|
||||
},
|
||||
keepalive: true,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (response.ok) return;
|
||||
const err = new Error(`HTTP ${response.status}`) as Error & { status: number };
|
||||
err.status = response.status;
|
||||
|
|
@ -274,6 +273,7 @@ export class Reporter {
|
|||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url, !isSync);
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-sentry-envelope');
|
||||
xhr.timeout = 10000;
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
|
|
@ -284,6 +284,7 @@ export class Reporter {
|
|||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Network error'));
|
||||
xhr.ontimeout = () => reject(new Error('Request timeout'));
|
||||
xhr.send(body);
|
||||
});
|
||||
}
|
||||
|
|
@ -323,7 +324,8 @@ export class Reporter {
|
|||
const url = getEnvelopeUrl(this.dsn);
|
||||
const img = new Image();
|
||||
const encoded = encodeURIComponent(btoa(body));
|
||||
img.src = url + '&sentry_data=' + encoded.substring(0, 2000);
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
img.src = url + separator + 'sentry_data=' + encoded.substring(0, 2000);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
|
|||
11
src/index.ts
11
src/index.ts
|
|
@ -4,6 +4,7 @@ import PerformancePlugin from './plugins/PerformancePlugin';
|
|||
import NetworkPlugin from './plugins/NetworkPlugin';
|
||||
import BehaviorPlugin from './plugins/BehaviorPlugin';
|
||||
import OfflinePlugin from './plugins/OfflinePlugin';
|
||||
import SamplingPlugin from './plugins/SamplingPlugin';
|
||||
import type { LightConfig, LightClient, SentryEvent, EventLevel, UserInfo, Breadcrumb, LightPlugin, ErrorEvent, PerformanceEvent, NetworkEvent, BehaviorEvent, DSNInfo } from './types';
|
||||
|
||||
let globalClient: LightClient | null = null;
|
||||
|
|
@ -12,8 +13,9 @@ function init(config: LightConfig): LightClient {
|
|||
const client = new Client(config);
|
||||
|
||||
const defaultPlugins: LightPlugin[] = [
|
||||
new ErrorPlugin(),
|
||||
new OfflinePlugin(),
|
||||
new SamplingPlugin(),
|
||||
new ErrorPlugin(),
|
||||
];
|
||||
|
||||
const pluginNames = config.plugins?.filter(p => typeof p === 'string') as string[] || [];
|
||||
|
|
@ -29,18 +31,18 @@ function init(config: LightConfig): LightClient {
|
|||
}
|
||||
|
||||
for (const plugin of defaultPlugins) {
|
||||
(client as unknown as { use: (p: LightPlugin) => void }).use(plugin);
|
||||
client.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.use(plugin as LightPlugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(client as unknown as { init: () => void }).init();
|
||||
client.init();
|
||||
|
||||
globalClient = client;
|
||||
return client;
|
||||
|
|
@ -114,6 +116,7 @@ export {
|
|||
NetworkPlugin,
|
||||
BehaviorPlugin,
|
||||
OfflinePlugin,
|
||||
SamplingPlugin,
|
||||
};
|
||||
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { LightPlugin, LightClient, BehaviorEvent, LightConfig } from '../types';
|
||||
import { now, isObject } from '../utils/helper';
|
||||
import { getPageUrl } from '../utils/env';
|
||||
import { getPageUrl, sanitizeUrl } from '../utils/env';
|
||||
|
||||
interface BehaviorPluginConfig {
|
||||
capturePV?: boolean;
|
||||
|
|
@ -26,7 +26,7 @@ class BehaviorPlugin implements LightPlugin {
|
|||
captureScroll: true,
|
||||
clickThrottle: 300,
|
||||
scrollThrottle: 1000,
|
||||
sampleRate: 0.1,
|
||||
sampleRate: 0.01,
|
||||
};
|
||||
|
||||
private lastClickTime: number = 0;
|
||||
|
|
@ -35,14 +35,20 @@ class BehaviorPlugin implements LightPlugin {
|
|||
private pageEnterTime: number = 0;
|
||||
private scrollReported: boolean = false;
|
||||
|
||||
private originalPushState?: typeof history.pushState;
|
||||
private originalReplaceState?: typeof history.replaceState;
|
||||
private onScroll?: () => void;
|
||||
private onClick?: (e: Event) => void;
|
||||
private onVisibilityChange?: () => void;
|
||||
private onBeforeUnload?: () => void;
|
||||
private onPagehide?: () => void;
|
||||
private onPopState?: () => void;
|
||||
private onHashchange?: () => void;
|
||||
|
||||
setup(client: LightClient): void {
|
||||
this.client = client;
|
||||
this.loadConfig();
|
||||
|
||||
if (!this.shouldSample()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.capturePV) {
|
||||
this.trackPV();
|
||||
}
|
||||
|
|
@ -68,7 +74,7 @@ class BehaviorPlugin implements LightPlugin {
|
|||
}
|
||||
|
||||
private shouldSample(): boolean {
|
||||
const rate = this.config.sampleRate ?? 0.1;
|
||||
const rate = this.config.sampleRate ?? 0.01;
|
||||
if (rate >= 1) return true;
|
||||
if (rate <= 0) return false;
|
||||
return Math.random() < rate;
|
||||
|
|
@ -90,7 +96,7 @@ class BehaviorPlugin implements LightPlugin {
|
|||
if (typeof window === 'undefined') return;
|
||||
|
||||
const url = getPageUrl();
|
||||
const referrer = document.referrer;
|
||||
const referrer = sanitizeUrl(document.referrer || '');
|
||||
|
||||
this.reportBehavior({
|
||||
sub_type: 'pv',
|
||||
|
|
@ -111,7 +117,7 @@ class BehaviorPlugin implements LightPlugin {
|
|||
private trackClick(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
this.onClick = (e: Event) => {
|
||||
const currentTime = now();
|
||||
const throttle = this.config.clickThrottle ?? 300;
|
||||
if (currentTime - this.lastClickTime < throttle) {
|
||||
|
|
@ -142,11 +148,13 @@ class BehaviorPlugin implements LightPlugin {
|
|||
selector,
|
||||
text: text || undefined,
|
||||
tag: target.tagName?.toLowerCase(),
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
x: (e as MouseEvent).clientX,
|
||||
y: (e as MouseEvent).clientY,
|
||||
},
|
||||
});
|
||||
}, true);
|
||||
};
|
||||
|
||||
document.addEventListener('click', this.onClick, true);
|
||||
}
|
||||
|
||||
private trackRoute(): void {
|
||||
|
|
@ -177,28 +185,27 @@ class BehaviorPlugin implements LightPlugin {
|
|||
}
|
||||
};
|
||||
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
this.originalPushState = history.pushState;
|
||||
this.originalReplaceState = history.replaceState;
|
||||
const self = this;
|
||||
|
||||
history.pushState = function () {
|
||||
const result = originalPushState.apply(this, arguments as unknown as Parameters<typeof history.pushState>);
|
||||
const result = self.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>);
|
||||
const result = self.originalReplaceState!.apply(this, arguments as unknown as Parameters<typeof history.replaceState>);
|
||||
setTimeout(checkRoute, 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
setTimeout(checkRoute, 0);
|
||||
});
|
||||
this.onPopState = () => { setTimeout(checkRoute, 0); };
|
||||
this.onHashchange = () => { setTimeout(checkRoute, 0); };
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
setTimeout(checkRoute, 0);
|
||||
});
|
||||
window.addEventListener('popstate', this.onPopState);
|
||||
window.addEventListener('hashchange', this.onHashchange);
|
||||
}
|
||||
|
||||
private trackPageDuration(): void {
|
||||
|
|
@ -224,22 +231,26 @@ class BehaviorPlugin implements LightPlugin {
|
|||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
this.onVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
sendDuration();
|
||||
} else {
|
||||
this.pageEnterTime = now();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', sendDuration);
|
||||
window.addEventListener('pagehide', sendDuration);
|
||||
this.onBeforeUnload = sendDuration;
|
||||
this.onPagehide = sendDuration;
|
||||
|
||||
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
window.addEventListener('beforeunload', this.onBeforeUnload);
|
||||
window.addEventListener('pagehide', this.onPagehide);
|
||||
}
|
||||
|
||||
private trackScroll(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const onScroll = () => {
|
||||
this.onScroll = () => {
|
||||
const currentTime = now();
|
||||
const throttle = this.config.scrollThrottle ?? 1000;
|
||||
if (currentTime - this.lastScrollTime < throttle) {
|
||||
|
|
@ -253,7 +264,7 @@ class BehaviorPlugin implements LightPlugin {
|
|||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('scroll', this.onScroll, { passive: true });
|
||||
}
|
||||
|
||||
private reportScroll(): void {
|
||||
|
|
@ -270,6 +281,8 @@ class BehaviorPlugin implements LightPlugin {
|
|||
}
|
||||
|
||||
private reportBehavior(data: Omit<BehaviorEvent, 'type' | 'level' | 'timestamp'>): void {
|
||||
if (!this.shouldSample()) return;
|
||||
|
||||
const event: BehaviorEvent = {
|
||||
type: 'behavior',
|
||||
level: 'info',
|
||||
|
|
@ -284,7 +297,39 @@ class BehaviorPlugin implements LightPlugin {
|
|||
this.client.captureEvent(event);
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
destroy(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (this.originalPushState) {
|
||||
history.pushState = this.originalPushState;
|
||||
}
|
||||
if (this.originalReplaceState) {
|
||||
history.replaceState = this.originalReplaceState;
|
||||
}
|
||||
if (this.onPopState) {
|
||||
window.removeEventListener('popstate', this.onPopState);
|
||||
}
|
||||
if (this.onHashchange) {
|
||||
window.removeEventListener('hashchange', this.onHashchange);
|
||||
}
|
||||
if (this.onBeforeUnload) {
|
||||
window.removeEventListener('beforeunload', this.onBeforeUnload);
|
||||
}
|
||||
if (this.onPagehide) {
|
||||
window.removeEventListener('pagehide', this.onPagehide);
|
||||
}
|
||||
if (this.onScroll) {
|
||||
window.removeEventListener('scroll', this.onScroll);
|
||||
}
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
if (this.onClick) {
|
||||
document.removeEventListener('click', this.onClick, true);
|
||||
}
|
||||
if (this.onVisibilityChange) {
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default BehaviorPlugin;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import type { LightPlugin, LightClient, ErrorEvent, StackFrame, LightConfig } from '../types';
|
||||
import { now, isObject } from '../utils/helper';
|
||||
import { now, isObject, matchPatterns } from '../utils/helper';
|
||||
import { computeFingerprint } from '../utils/hash';
|
||||
import { shouldIgnoreUrl } from '../utils/env';
|
||||
import { sanitizeUrl, shouldIgnoreUrl } from '../utils/env';
|
||||
import { parseStackTrace } from '../utils/stacktrace';
|
||||
|
||||
interface ErrorPluginConfig {
|
||||
maxStackFrames?: number;
|
||||
captureNodeModules?: boolean;
|
||||
captureColumn?: boolean;
|
||||
relativePathOnly?: boolean;
|
||||
sampleRate?: number;
|
||||
resourceSampleRate?: number;
|
||||
ignoreErrors?: (string | RegExp)[];
|
||||
ignoreUrls?: (string | RegExp)[];
|
||||
includePaths?: (string | RegExp)[];
|
||||
}
|
||||
|
||||
class ErrorPlugin implements LightPlugin {
|
||||
|
|
@ -22,8 +26,13 @@ class ErrorPlugin implements LightPlugin {
|
|||
captureNodeModules: false,
|
||||
captureColumn: true,
|
||||
relativePathOnly: false,
|
||||
resourceSampleRate: 0.1,
|
||||
};
|
||||
|
||||
private originalOnError?: typeof window.onerror;
|
||||
private onUnhandledRejection?: (event: PromiseRejectionEvent) => void;
|
||||
private onResourceError?: (event: Event) => void;
|
||||
|
||||
setup(client: LightClient): void {
|
||||
this.client = client;
|
||||
this.loadConfig();
|
||||
|
|
@ -43,31 +52,61 @@ class ErrorPlugin implements LightPlugin {
|
|||
if (clientConfig.ignoreUrls) {
|
||||
this.config.ignoreUrls = [...(this.config.ignoreUrls || []), ...clientConfig.ignoreUrls];
|
||||
}
|
||||
if (clientConfig.includePaths) {
|
||||
this.config.includePaths = [...(this.config.includePaths || []), ...clientConfig.includePaths];
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
if (!message) return false;
|
||||
return matchPatterns(message, this.config.ignoreErrors || []);
|
||||
}
|
||||
|
||||
private shouldIgnoreScriptUrl(url: string): boolean {
|
||||
return shouldIgnoreUrl(url, this.config.ignoreUrls);
|
||||
return shouldIgnoreUrl(url, this.config.ignoreUrls || []);
|
||||
}
|
||||
|
||||
private shouldIncludePath(frames: StackFrame[]): boolean {
|
||||
const patterns = this.config.includePaths || [];
|
||||
if (patterns.length === 0) return true;
|
||||
|
||||
return frames.some(frame =>
|
||||
patterns.some(pattern => {
|
||||
if (typeof pattern === 'string') {
|
||||
return frame.filename?.includes(pattern);
|
||||
}
|
||||
return pattern.test(frame.filename || '');
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private shouldSample(): boolean {
|
||||
const rate = this.config.sampleRate ?? 1;
|
||||
if (rate >= 1) return true;
|
||||
if (rate <= 0) return false;
|
||||
return Math.random() < rate;
|
||||
}
|
||||
|
||||
private shouldSampleResource(): boolean {
|
||||
const rate = this.config.resourceSampleRate ?? 0.1;
|
||||
if (rate >= 1) return true;
|
||||
if (rate <= 0) return false;
|
||||
return Math.random() < rate;
|
||||
}
|
||||
|
||||
private setupGlobalError(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const originalOnError = window.onerror;
|
||||
this.originalOnError = window.onerror;
|
||||
|
||||
window.onerror = (message, url, lineno, colno, error) => {
|
||||
try {
|
||||
if (originalOnError) {
|
||||
originalOnError.call(window, message, url, lineno, colno, error);
|
||||
if (this.originalOnError) {
|
||||
this.originalOnError.call(window, message, url, lineno, colno, error);
|
||||
}
|
||||
|
||||
if (url && this.shouldIgnoreScriptUrl(url as string)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const errorEvent = this.buildErrorEvent(
|
||||
|
|
@ -91,9 +130,15 @@ class ErrorPlugin implements LightPlugin {
|
|||
private setupUnhandledRejection(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
this.onUnhandledRejection = (event) => {
|
||||
try {
|
||||
const reason = event.reason;
|
||||
const sourceUrl = this.extractUrlFromReason(reason);
|
||||
|
||||
if (sourceUrl && this.shouldIgnoreScriptUrl(sourceUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorEvent = this.buildErrorEvent(reason);
|
||||
|
||||
if (errorEvent) {
|
||||
|
|
@ -110,13 +155,29 @@ class ErrorPlugin implements LightPlugin {
|
|||
} catch (e) {
|
||||
console.error('[LightSDK] ErrorPlugin unhandledrejection handler error:', e);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('unhandledrejection', this.onUnhandledRejection);
|
||||
}
|
||||
|
||||
private extractUrlFromReason(reason: unknown): string | null {
|
||||
if (reason instanceof Error && reason.stack) {
|
||||
const match = reason.stack.match(/at\s+(?:.+?\s+)?\((https?:\/\/[^:]+):\d+:\d+\)/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
const urlMatch = reason.stack.match(/(https?:\/\/[^\s)]+):\d+:\d+/);
|
||||
if (urlMatch) {
|
||||
return urlMatch[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private setupResourceError(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
this.onResourceError = (event) => {
|
||||
const target = event.target;
|
||||
if (!target) return;
|
||||
|
||||
|
|
@ -125,14 +186,20 @@ class ErrorPlugin implements LightPlugin {
|
|||
|
||||
if (tagName && src && ['img', 'script', 'link', 'audio', 'video'].includes(tagName)) {
|
||||
try {
|
||||
if (this.shouldIgnoreScriptUrl(src)) return;
|
||||
if (!this.shouldSampleResource()) return;
|
||||
|
||||
const fingerprint = `resource:${tagName}`;
|
||||
const sanitizedSrc = sanitizeUrl(src);
|
||||
this.client.captureEvent({
|
||||
type: 'error',
|
||||
level: 'warning',
|
||||
message: `Resource load failed: ${tagName} ${src}`,
|
||||
message: `Resource load failed: ${tagName} ${sanitizedSrc}`,
|
||||
timestamp: now(),
|
||||
fingerprint,
|
||||
tags: {
|
||||
resource_type: tagName,
|
||||
resource_url: src,
|
||||
resource_url: sanitizedSrc,
|
||||
},
|
||||
exception: {
|
||||
type: 'ResourceError',
|
||||
|
|
@ -143,7 +210,9 @@ class ErrorPlugin implements LightPlugin {
|
|||
console.error('[LightSDK] ErrorPlugin resource error handler error:', e);
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
};
|
||||
|
||||
window.addEventListener('error', this.onResourceError, true);
|
||||
}
|
||||
|
||||
private buildErrorEvent(error: Error | unknown, url?: string, lineno?: number, colno?: number): Partial<ErrorEvent> & { type: string } | null {
|
||||
|
|
@ -152,7 +221,16 @@ class ErrorPlugin implements LightPlugin {
|
|||
if (error instanceof Error) {
|
||||
if (this.shouldIgnoreError(error.message)) return null;
|
||||
|
||||
const frames = this.parseStackTrace(error.stack);
|
||||
const frames = parseStackTrace(error.stack, {
|
||||
captureNodeModules: this.config.captureNodeModules,
|
||||
captureColumn: this.config.captureColumn,
|
||||
relativePathOnly: this.config.relativePathOnly,
|
||||
maxFrames: this.config.maxStackFrames,
|
||||
});
|
||||
|
||||
if (!this.shouldIncludePath(frames)) return null;
|
||||
if (!this.shouldSample()) return null;
|
||||
|
||||
const fingerprint = computeFingerprint(error.name, error.message, frames);
|
||||
|
||||
return {
|
||||
|
|
@ -164,7 +242,7 @@ class ErrorPlugin implements LightPlugin {
|
|||
type: error.name,
|
||||
value: error.message,
|
||||
stacktrace: {
|
||||
frames: frames.slice(0, this.config.maxStackFrames || 5),
|
||||
frames,
|
||||
},
|
||||
},
|
||||
fingerprint,
|
||||
|
|
@ -184,6 +262,9 @@ class ErrorPlugin implements LightPlugin {
|
|||
});
|
||||
}
|
||||
|
||||
if (!this.shouldIncludePath(frames)) return null;
|
||||
if (!this.shouldSample()) return null;
|
||||
|
||||
return {
|
||||
type: 'error',
|
||||
level: 'error',
|
||||
|
|
@ -199,64 +280,19 @@ class ErrorPlugin implements LightPlugin {
|
|||
};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
destroy(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (this.originalOnError !== undefined) {
|
||||
window.onerror = this.originalOnError;
|
||||
}
|
||||
if (this.onUnhandledRejection) {
|
||||
window.removeEventListener('unhandledrejection', this.onUnhandledRejection);
|
||||
}
|
||||
if (this.onResourceError) {
|
||||
window.removeEventListener('error', this.onResourceError, true);
|
||||
}
|
||||
}
|
||||
|
||||
return frames.reverse();
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
}
|
||||
|
||||
export default ErrorPlugin;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { sanitizeUrl, shouldIgnoreUrl } from '../utils/env';
|
|||
|
||||
interface NetworkPluginConfig {
|
||||
ignoreUrls?: (string | RegExp)[];
|
||||
captureXHR?: boolean;
|
||||
captureFetch?: boolean;
|
||||
captureSuccess?: boolean;
|
||||
captureBody?: boolean;
|
||||
captureRequestBody?: boolean;
|
||||
|
|
@ -23,6 +25,8 @@ class NetworkPlugin implements LightPlugin {
|
|||
|
||||
private client!: LightClient;
|
||||
private config: NetworkPluginConfig = {
|
||||
captureXHR: true,
|
||||
captureFetch: true,
|
||||
captureSuccess: false,
|
||||
captureBody: false,
|
||||
captureRequestBody: false,
|
||||
|
|
@ -43,8 +47,12 @@ class NetworkPlugin implements LightPlugin {
|
|||
setup(client: LightClient): void {
|
||||
this.client = client;
|
||||
this.loadConfig();
|
||||
this.patchFetch();
|
||||
this.patchXHR();
|
||||
if (this.config.captureFetch) {
|
||||
this.patchFetch();
|
||||
}
|
||||
if (this.config.captureXHR) {
|
||||
this.patchXHR();
|
||||
}
|
||||
}
|
||||
|
||||
private loadConfig(): void {
|
||||
|
|
@ -151,6 +159,7 @@ class NetworkPlugin implements LightPlugin {
|
|||
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();
|
||||
(this as { _lightTracked?: boolean })._lightTracked = false;
|
||||
return self.originalXHROpen.apply(this, arguments as unknown as Parameters<typeof XMLHttpRequest.prototype.open>);
|
||||
};
|
||||
|
||||
|
|
@ -158,67 +167,59 @@ class NetworkPlugin implements LightPlugin {
|
|||
const startTime = now();
|
||||
const method = (this as { _lightMethod?: string })._lightMethod || 'GET';
|
||||
const url = (this as { _lightUrl?: string })._lightUrl || '';
|
||||
const tracked = (this as { _lightTracked?: boolean })._lightTracked;
|
||||
|
||||
if (self.shouldIgnore(url)) {
|
||||
if (self.shouldIgnore(url) || tracked) {
|
||||
return self.originalXHRSend.apply(this, arguments as unknown as Parameters<typeof XMLHttpRequest.prototype.send>);
|
||||
}
|
||||
|
||||
(this as { _lightTracked?: boolean })._lightTracked = true;
|
||||
const sanitizedUrl = sanitizeUrl(url);
|
||||
let requestSize = 0;
|
||||
if (body && typeof body === 'string') {
|
||||
requestSize = body.length;
|
||||
}
|
||||
|
||||
const onLoadEnd = () => {
|
||||
const originalOnLoadEnd = (this as XMLHttpRequest & { onloadend?: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null }).onloadend;
|
||||
(this as XMLHttpRequest & { onloadend?: ((this: XMLHttpRequest, ev: ProgressEvent) => unknown) | null }).onloadend = function (ev: ProgressEvent) {
|
||||
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 (!self.config.ignoreStatusCodes?.includes(status)
|
||||
&& self.shouldSample(!isSuccess, duration)
|
||||
&& (isSuccess ? self.config.captureSuccess : true)) {
|
||||
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
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
if (originalOnLoadEnd) {
|
||||
return originalOnLoadEnd.call(this, ev);
|
||||
}
|
||||
};
|
||||
|
||||
this.addEventListener('loadend', onLoadEnd);
|
||||
|
||||
return self.originalXHRSend.apply(this, arguments as unknown as Parameters<typeof XMLHttpRequest.prototype.send>);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ class OfflinePlugin implements LightPlugin {
|
|||
private db: IDBDatabase | null = null;
|
||||
private isOnline: boolean = true;
|
||||
private isSyncing: boolean = false;
|
||||
private onOnline?: () => void;
|
||||
private onOffline?: () => void;
|
||||
private pendingEvents: SentryEvent[] = [];
|
||||
|
||||
setup(client: LightClient): void {
|
||||
|
|
@ -57,10 +59,15 @@ class OfflinePlugin implements LightPlugin {
|
|||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
const currentVersion = event.oldVersion;
|
||||
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
}
|
||||
|
||||
// 未来版本迁移可以在这里处理:
|
||||
// if (currentVersion < 2) { ... }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -70,16 +77,19 @@ class OfflinePlugin implements LightPlugin {
|
|||
|
||||
this.isOnline = navigator.onLine;
|
||||
|
||||
window.addEventListener('online', () => {
|
||||
this.onOnline = () => {
|
||||
console.info('[LightSDK] OfflinePlugin: Network online');
|
||||
this.isOnline = true;
|
||||
this.syncPendingEvents();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.onOffline = () => {
|
||||
console.info('[LightSDK] OfflinePlugin: Network offline');
|
||||
this.isOnline = false;
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('online', this.onOnline);
|
||||
window.addEventListener('offline', this.onOffline);
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
|
|
@ -89,32 +99,41 @@ class OfflinePlugin implements LightPlugin {
|
|||
private async saveToIndexedDB(event: SentryEvent): Promise<void> {
|
||||
if (!this.db) return;
|
||||
|
||||
const maxEvents = this.config.maxEvents || MAX_EVENTS;
|
||||
|
||||
const count = await this.getEventCount();
|
||||
if (count >= maxEvents) {
|
||||
await this.deleteOldestEvents(count - maxEvents + 1);
|
||||
}
|
||||
|
||||
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);
|
||||
const offlineEvent: OfflineEvent = {
|
||||
id: this.generateId(),
|
||||
event: {
|
||||
...event,
|
||||
offline: true,
|
||||
} as unknown as SentryEvent,
|
||||
timestamp: now(),
|
||||
};
|
||||
|
||||
const addRequest = store.add(offlineEvent);
|
||||
addRequest.onsuccess = () => resolve();
|
||||
addRequest.onerror = () => reject(addRequest.error);
|
||||
});
|
||||
}
|
||||
|
||||
private getEventCount(): Promise<number> {
|
||||
if (!this.db) return Promise.resolve(0);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction([STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const countRequest = store.count();
|
||||
countRequest.onsuccess = () => resolve(countRequest.result);
|
||||
countRequest.onerror = () => reject(countRequest.error);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -184,42 +203,43 @@ class OfflinePlugin implements LightPlugin {
|
|||
console.info('[LightSDK] OfflinePlugin: Syncing pending events');
|
||||
|
||||
try {
|
||||
const storedEvents = await this.getAllStoredEvents();
|
||||
let syncedCount = 0;
|
||||
|
||||
if (storedEvents.length === 0) {
|
||||
console.info('[LightSDK] OfflinePlugin: No pending events to sync');
|
||||
this.isSyncing = false;
|
||||
return;
|
||||
// 先处理内存中的事件
|
||||
while (this.pendingEvents.length > 0) {
|
||||
const event = this.pendingEvents.shift() as Partial<SentryEvent> & { type: string };
|
||||
try {
|
||||
this.client.captureEvent(event);
|
||||
syncedCount++;
|
||||
} catch (error) {
|
||||
console.error('[LightSDK] OfflinePlugin: Failed to re-enqueue memory event', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 分批上报,每次最多 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);
|
||||
// 继续同步其他批次
|
||||
// 再处理 IndexedDB 中的事件
|
||||
const storedEvents = await this.getAllStoredEvents();
|
||||
if (storedEvents.length > 0) {
|
||||
for (const offlineEvent of storedEvents) {
|
||||
try {
|
||||
const event = offlineEvent.event as Partial<SentryEvent> & { type: string };
|
||||
this.client.captureEvent(event);
|
||||
syncedCount++;
|
||||
} catch (error) {
|
||||
console.error('[LightSDK] OfflinePlugin: Failed to re-enqueue event', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 等待队列 flush
|
||||
try {
|
||||
await this.client.flush();
|
||||
} catch (e) {
|
||||
// flush 失败不影响,事件已在队列中会自动重试
|
||||
}
|
||||
|
||||
// 清空已同步的事件
|
||||
await this.clearStoredEvents();
|
||||
console.info('[LightSDK] OfflinePlugin: All pending events synced');
|
||||
console.info(`[LightSDK] OfflinePlugin: Synced ${syncedCount} events`);
|
||||
} catch (error) {
|
||||
console.error('[LightSDK] OfflinePlugin: Sync failed', error);
|
||||
} finally {
|
||||
|
|
@ -229,22 +249,51 @@ class OfflinePlugin implements LightPlugin {
|
|||
|
||||
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 表示不上报(因为已经缓存了)
|
||||
const maxEvents = this.config.maxEvents || MAX_EVENTS;
|
||||
if (this.pendingEvents.length >= maxEvents) {
|
||||
this.pendingEvents.shift();
|
||||
}
|
||||
this.pendingEvents.push(event);
|
||||
this.savePendingEventsToDB();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 在线时,标记为非离线事件
|
||||
const onlineEvent = { ...event };
|
||||
delete (onlineEvent as Record<string, unknown>).offline;
|
||||
return onlineEvent as SentryEvent;
|
||||
}
|
||||
|
||||
private savePendingEventsToDB(): void {
|
||||
if (this.pendingEvents.length === 0) return;
|
||||
|
||||
const events = this.pendingEvents.splice(0, this.pendingEvents.length);
|
||||
let savedCount = 0;
|
||||
|
||||
const saveNext = () => {
|
||||
if (savedCount >= events.length) return;
|
||||
const event = events[savedCount];
|
||||
this.saveToIndexedDB(event).then(() => {
|
||||
savedCount++;
|
||||
saveNext();
|
||||
}).catch(err => {
|
||||
console.error('[LightSDK] OfflinePlugin: Failed to save event offline', err);
|
||||
// 恢复剩余未保存的事件
|
||||
this.pendingEvents.unshift(...events.slice(savedCount));
|
||||
});
|
||||
};
|
||||
|
||||
saveNext();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
// 清理资源
|
||||
if (typeof window !== 'undefined') {
|
||||
if (this.onOnline) {
|
||||
window.removeEventListener('online', this.onOnline);
|
||||
}
|
||||
if (this.onOffline) {
|
||||
window.removeEventListener('offline', this.onOffline);
|
||||
}
|
||||
}
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
|
|
|
|||
|
|
@ -26,14 +26,19 @@ class PerformancePlugin implements LightPlugin {
|
|||
captureFP: true,
|
||||
};
|
||||
|
||||
private observers: PerformanceObserver[] = [];
|
||||
private onLoad?: () => void;
|
||||
private navLoadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private totalBlockingTime: number = 0;
|
||||
private fcpTime: number | null = null;
|
||||
private lastLongTaskEndTime: number = 0;
|
||||
private ttiReported: boolean = false;
|
||||
private ttiTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
setup(client: LightClient): void {
|
||||
this.client = client;
|
||||
this.loadConfig();
|
||||
|
||||
if (!this.shouldSample('default')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.observeWebVitals();
|
||||
this.observeLongTasks();
|
||||
this.observeNavigation();
|
||||
|
|
@ -47,12 +52,14 @@ class PerformancePlugin implements LightPlugin {
|
|||
}
|
||||
}
|
||||
|
||||
private shouldSample(type: 'default' | 'resource' | 'longtask' = 'default'): boolean {
|
||||
private shouldSample(type: 'default' | 'resource' | 'longtask' | 'vitals' = '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;
|
||||
} else if (type === 'vitals') {
|
||||
rate = 1;
|
||||
}
|
||||
if (rate >= 1) return true;
|
||||
if (rate <= 0) return false;
|
||||
|
|
@ -77,10 +84,11 @@ class PerformancePlugin implements LightPlugin {
|
|||
const po = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntriesByName('first-paint');
|
||||
if (entries.length > 0) {
|
||||
this.reportMetric('FP', entries[0].startTime, 'ms');
|
||||
this.reportMetric('FP', entries[0].startTime, 'ms', {}, 'vitals');
|
||||
}
|
||||
});
|
||||
po.observe({ type: 'paint', buffered: true });
|
||||
this.observers.push(po);
|
||||
} catch {
|
||||
// FP not supported
|
||||
}
|
||||
|
|
@ -93,10 +101,11 @@ class PerformancePlugin implements LightPlugin {
|
|||
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');
|
||||
this.reportMetric('LCP', value, 'ms', {}, 'vitals');
|
||||
}
|
||||
});
|
||||
po.observe({ type: 'largest-contentful-paint', buffered: true });
|
||||
this.observers.push(po);
|
||||
} catch {
|
||||
// LCP not supported
|
||||
}
|
||||
|
|
@ -109,10 +118,11 @@ class PerformancePlugin implements LightPlugin {
|
|||
if (entries.length > 0) {
|
||||
const firstEntry = entries[0];
|
||||
const value = firstEntry.processingStart - firstEntry.startTime;
|
||||
this.reportMetric('FID', value, 'ms');
|
||||
this.reportMetric('FID', value, 'ms', {}, 'vitals');
|
||||
}
|
||||
});
|
||||
po.observe({ type: 'first-input', buffered: true });
|
||||
this.observers.push(po);
|
||||
} catch {
|
||||
// FID not supported
|
||||
}
|
||||
|
|
@ -125,7 +135,7 @@ class PerformancePlugin implements LightPlugin {
|
|||
let sessionEntries: PerformanceEntry[] = [];
|
||||
|
||||
const po = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries() as PerformanceEntry & { hadRecentInput?: boolean; value?: number }[];
|
||||
const entries = entryList.getEntries() as unknown as { hadRecentInput?: boolean; value?: number; startTime: number; endTime?: number }[];
|
||||
for (const entry of entries) {
|
||||
if (!entry.hadRecentInput) {
|
||||
const firstSessionEntry = sessionEntries[0];
|
||||
|
|
@ -133,24 +143,25 @@ class PerformancePlugin implements LightPlugin {
|
|||
|
||||
if (
|
||||
sessionValue &&
|
||||
entry.startTime - (lastSessionEntry as { endTime?: number }).endTime! < 1000 &&
|
||||
entry.startTime - (lastSessionEntry?.duration || 0) < 1000 &&
|
||||
entry.startTime - firstSessionEntry.startTime < 5000
|
||||
) {
|
||||
sessionValue += entry.value || 0;
|
||||
sessionEntries.push(entry as PerformanceEntry);
|
||||
sessionEntries.push(entry as unknown as PerformanceEntry);
|
||||
} else {
|
||||
sessionValue = entry.value || 0;
|
||||
sessionEntries = [entry as PerformanceEntry];
|
||||
sessionEntries = [entry as unknown as PerformanceEntry];
|
||||
}
|
||||
|
||||
if (sessionValue > clsValue) {
|
||||
clsValue = sessionValue;
|
||||
this.reportMetric('CLS', clsValue, '');
|
||||
this.reportMetric('CLS', clsValue, '', {}, 'vitals');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
po.observe({ type: 'layout-shift', buffered: true });
|
||||
this.observers.push(po);
|
||||
} catch {
|
||||
// CLS not supported
|
||||
}
|
||||
|
|
@ -161,10 +172,14 @@ class PerformancePlugin implements LightPlugin {
|
|||
const po = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntriesByName('first-contentful-paint');
|
||||
if (entries.length > 0) {
|
||||
this.reportMetric('FCP', entries[0].startTime, 'ms');
|
||||
this.fcpTime = entries[0].startTime;
|
||||
this.lastLongTaskEndTime = entries[0].startTime;
|
||||
this.reportMetric('FCP', entries[0].startTime, 'ms', {}, 'vitals');
|
||||
this.scheduleTTI();
|
||||
}
|
||||
});
|
||||
po.observe({ type: 'paint', buffered: true });
|
||||
this.observers.push(po);
|
||||
} catch {
|
||||
// FCP not supported
|
||||
}
|
||||
|
|
@ -177,10 +192,11 @@ class PerformancePlugin implements LightPlugin {
|
|||
if (navEntries.length > 0) {
|
||||
const navEntry = navEntries[0];
|
||||
const ttfb = navEntry.responseStart - navEntry.requestStart;
|
||||
this.reportMetric('TTFB', Math.max(0, ttfb), 'ms');
|
||||
this.reportMetric('TTFB', Math.max(0, ttfb), 'ms', {}, 'vitals');
|
||||
}
|
||||
});
|
||||
po.observe({ type: 'navigation', buffered: true });
|
||||
this.observers.push(po);
|
||||
} catch {
|
||||
// Navigation timing not supported
|
||||
}
|
||||
|
|
@ -193,23 +209,53 @@ class PerformancePlugin implements LightPlugin {
|
|||
const po = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries();
|
||||
for (const entry of entries) {
|
||||
if (this.shouldSample('longtask')) {
|
||||
this.reportMetric('longtask', entry.duration, 'ms');
|
||||
const endTime = entry.startTime + entry.duration;
|
||||
if (endTime > this.lastLongTaskEndTime) {
|
||||
this.lastLongTaskEndTime = endTime;
|
||||
}
|
||||
|
||||
if (this.fcpTime !== null && entry.startTime >= this.fcpTime) {
|
||||
const blockingTime = entry.duration - 50;
|
||||
if (blockingTime > 0) {
|
||||
this.totalBlockingTime += blockingTime;
|
||||
}
|
||||
}
|
||||
|
||||
this.reportMetric('longtask', entry.duration, 'ms', {}, 'longtask');
|
||||
|
||||
this.scheduleTTI();
|
||||
}
|
||||
});
|
||||
po.observe({ type: 'longtask', buffered: true });
|
||||
this.observers.push(po);
|
||||
} catch {
|
||||
// Long tasks not supported
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleTTI(): void {
|
||||
if (this.ttiReported) return;
|
||||
|
||||
if (this.ttiTimer) {
|
||||
clearTimeout(this.ttiTimer);
|
||||
}
|
||||
|
||||
this.ttiTimer = setTimeout(() => {
|
||||
if (this.ttiReported) return;
|
||||
this.ttiReported = true;
|
||||
|
||||
const tti = Math.max(this.lastLongTaskEndTime, this.fcpTime || 0);
|
||||
this.reportMetric('TTI', tti, 'ms', {}, 'vitals');
|
||||
this.reportMetric('TBT', this.totalBlockingTime, 'ms', {}, 'vitals');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private observeNavigation(): void {
|
||||
if (!this.config.captureNavigation) return;
|
||||
if (typeof performance === 'undefined') return;
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
this.onLoad = () => {
|
||||
this.navLoadTimer = setTimeout(() => {
|
||||
const timing = performance.timing;
|
||||
if (!timing) return;
|
||||
|
||||
|
|
@ -230,8 +276,11 @@ class PerformancePlugin implements LightPlugin {
|
|||
this.reportMetric(name, value, 'ms');
|
||||
}
|
||||
}
|
||||
this.navLoadTimer = null;
|
||||
}, 0);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('load', this.onLoad);
|
||||
}
|
||||
|
||||
private observeResources(): void {
|
||||
|
|
@ -240,19 +289,18 @@ class PerformancePlugin implements LightPlugin {
|
|||
|
||||
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,
|
||||
});
|
||||
}, 'resource');
|
||||
}
|
||||
}
|
||||
});
|
||||
po.observe({ type: 'resource', buffered: true });
|
||||
this.observers.push(po);
|
||||
} catch {
|
||||
// Resource timing not supported
|
||||
}
|
||||
|
|
@ -276,7 +324,9 @@ class PerformancePlugin implements LightPlugin {
|
|||
return 'poor';
|
||||
}
|
||||
|
||||
private reportMetric(name: string, value: number, unit: string, extraTags: Record<string, string> = {}): void {
|
||||
private reportMetric(name: string, value: number, unit: string, extraTags: Record<string, string> = {}, sampleType: 'default' | 'resource' | 'longtask' | 'vitals' = 'default'): void {
|
||||
if (!this.shouldSample(sampleType)) return;
|
||||
|
||||
const rating = this.getRating(name, value);
|
||||
|
||||
const event: PerformanceEvent = {
|
||||
|
|
@ -297,7 +347,30 @@ class PerformancePlugin implements LightPlugin {
|
|||
this.client.captureEvent(event);
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
destroy(): void {
|
||||
for (const observer of this.observers) {
|
||||
try {
|
||||
observer.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this.observers = [];
|
||||
|
||||
if (this.ttiTimer) {
|
||||
clearTimeout(this.ttiTimer);
|
||||
this.ttiTimer = null;
|
||||
}
|
||||
|
||||
if (this.navLoadTimer) {
|
||||
clearTimeout(this.navLoadTimer);
|
||||
this.navLoadTimer = null;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && this.onLoad) {
|
||||
window.removeEventListener('load', this.onLoad);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PerformancePlugin;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import type { LightPlugin, LightClient, SentryEvent, LightConfig } from '../types';
|
||||
import { isObject } from '../utils/helper';
|
||||
|
||||
interface SampleRateConfig {
|
||||
error?: number;
|
||||
performance?: number;
|
||||
network?: number;
|
||||
behavior?: number;
|
||||
[key: string]: number | undefined;
|
||||
}
|
||||
|
||||
interface SamplingPluginConfig {
|
||||
rates?: SampleRateConfig;
|
||||
}
|
||||
|
||||
class SamplingPlugin implements LightPlugin {
|
||||
name = 'sampling';
|
||||
version = '1.0.0';
|
||||
|
||||
private client!: LightClient;
|
||||
private config: SamplingPluginConfig = {
|
||||
rates: {
|
||||
error: 1,
|
||||
performance: 1,
|
||||
network: 1,
|
||||
behavior: 1,
|
||||
},
|
||||
};
|
||||
|
||||
setup(client: LightClient): void {
|
||||
this.client = client;
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
private loadConfig(): void {
|
||||
const clientConfig = this.client.config as LightConfig;
|
||||
if (clientConfig && isObject(clientConfig.sampling)) {
|
||||
const samplingConfig = clientConfig.sampling as object;
|
||||
if ('rates' in samplingConfig && isObject((samplingConfig as { rates?: object }).rates)) {
|
||||
this.config.rates = { ...this.config.rates, ...((samplingConfig as { rates: object }).rates as object) } as SampleRateConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldSample(event: SentryEvent): boolean {
|
||||
const globalRate = (this.client.config as LightConfig).sampleRate ?? 1;
|
||||
if (globalRate <= 0) return false;
|
||||
|
||||
const typeRate = this.getTypeRate(event);
|
||||
|
||||
if (globalRate >= 1) {
|
||||
return this.randomCheck(typeRate);
|
||||
}
|
||||
return this.randomCheck(globalRate * typeRate);
|
||||
}
|
||||
|
||||
private getTypeRate(event: SentryEvent): number {
|
||||
const rates = this.config.rates || {};
|
||||
const type = event.type;
|
||||
|
||||
if (type === 'count') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const rate = rates[type];
|
||||
return rate !== undefined ? rate : 1;
|
||||
}
|
||||
|
||||
private randomCheck(rate: number): boolean {
|
||||
if (rate >= 1) return true;
|
||||
if (rate <= 0) return false;
|
||||
return Math.random() < rate;
|
||||
}
|
||||
|
||||
beforeReport(event: SentryEvent): SentryEvent | null {
|
||||
if (!this.shouldSample(event)) {
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
// nothing to clean up
|
||||
}
|
||||
}
|
||||
|
||||
export default SamplingPlugin;
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
export type EventLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug';
|
||||
|
||||
export interface ContextLevelConfig {
|
||||
maxStackFrames: number;
|
||||
maxBreadcrumbs: number;
|
||||
}
|
||||
|
||||
export type ContextLevelMap = Record<EventLevel, ContextLevelConfig>;
|
||||
|
||||
export interface UserInfo {
|
||||
id?: string;
|
||||
username?: string;
|
||||
|
|
@ -131,6 +138,16 @@ export interface DSNInfo {
|
|||
projectId: string;
|
||||
}
|
||||
|
||||
export interface SamplingConfig {
|
||||
rates?: {
|
||||
error?: number;
|
||||
performance?: number;
|
||||
network?: number;
|
||||
behavior?: number;
|
||||
[key: string]: number | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LightConfig {
|
||||
dsn: string;
|
||||
release?: string;
|
||||
|
|
@ -144,9 +161,12 @@ export interface LightConfig {
|
|||
ignoreErrors?: (string | RegExp)[];
|
||||
ignoreUrls?: (string | RegExp)[];
|
||||
includePaths?: (string | RegExp)[];
|
||||
maxBreadcrumbs?: number;
|
||||
beforeSend?: (event: SentryEvent) => SentryEvent | null;
|
||||
user?: UserInfo;
|
||||
plugins?: (LightPlugin | string)[];
|
||||
contextLevel?: Partial<ContextLevelMap>;
|
||||
sampling?: SamplingConfig;
|
||||
[pluginName: string]: unknown;
|
||||
}
|
||||
|
||||
|
|
@ -176,4 +196,7 @@ export interface LightClient {
|
|||
flush(): Promise<void>;
|
||||
disable(): void;
|
||||
enable(): void;
|
||||
use(plugin: LightPlugin): void;
|
||||
init(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ export function parseDSN(dsn: string): DSNInfo {
|
|||
}
|
||||
|
||||
export function getEnvelopeUrl(dsn: DSNInfo): string {
|
||||
return `${dsn.protocol}://${dsn.host}/${dsn.projectId}/envelope/`;
|
||||
return `${dsn.protocol}://${dsn.host}/api/${dsn.projectId}/envelope/?sentry_key=${dsn.publicKey}`;
|
||||
}
|
||||
|
||||
export function getStoreUrl(dsn: DSNInfo): string {
|
||||
return `${dsn.protocol}://${dsn.host}/${dsn.projectId}/store/`;
|
||||
return `${dsn.protocol}://${dsn.host}/api/${dsn.projectId}/store/?sentry_key=${dsn.publicKey}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,6 +186,25 @@ export function sanitizeUrl(url: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
if (urlObj.hash && urlObj.hash.length > 1) {
|
||||
const hashContent = urlObj.hash.substring(1);
|
||||
if (hashContent.includes('=')) {
|
||||
const hashParams = new URLSearchParams(hashContent);
|
||||
let hashModified = false;
|
||||
for (const key of Array.from(hashParams.keys())) {
|
||||
const lowerKey = key.toLowerCase().replace(/[-_]/g, '_');
|
||||
if (sensitiveParams.some(s => lowerKey.includes(s))) {
|
||||
hashParams.set(key, '[Filtered]');
|
||||
hashModified = true;
|
||||
}
|
||||
}
|
||||
if (hashModified) {
|
||||
urlObj.hash = '#' + hashParams.toString();
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
return urlObj.toString();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,16 +8,9 @@ export function hashString(str: string): number {
|
|||
}
|
||||
|
||||
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');
|
||||
// 使用 hashString 替代不安全的 md5
|
||||
// 用于非加密场景(ID生成、指纹计算)
|
||||
return hashString(str).toString(16);
|
||||
}
|
||||
|
||||
export function computeFingerprint(type: string, message: string, frames: { filename: string; lineno?: number }[] = []): string {
|
||||
|
|
|
|||
|
|
@ -40,3 +40,13 @@ export function truncate(str: string, maxLen: number): string {
|
|||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + '...';
|
||||
}
|
||||
|
||||
export function matchPatterns(value: string, patterns: (string | RegExp)[]): boolean {
|
||||
if (!patterns || patterns.length === 0) return false;
|
||||
return patterns.some(pattern => {
|
||||
if (typeof pattern === 'string') {
|
||||
return value.includes(pattern);
|
||||
}
|
||||
return pattern.test(value);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
import type { StackFrame } from '../types';
|
||||
|
||||
export interface ParseStackOptions {
|
||||
captureNodeModules?: boolean;
|
||||
captureColumn?: boolean;
|
||||
relativePathOnly?: boolean;
|
||||
maxFrames?: number;
|
||||
}
|
||||
|
||||
export function parseStackTrace(stack?: string, options: ParseStackOptions = {}): StackFrame[] {
|
||||
if (!stack) return [];
|
||||
|
||||
const {
|
||||
captureNodeModules = false,
|
||||
captureColumn = true,
|
||||
relativePathOnly = false,
|
||||
maxFrames,
|
||||
} = options;
|
||||
|
||||
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 (!captureNodeModules && isNodeModules) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let finalFilename = filename;
|
||||
if (relativePathOnly && origin && filename.startsWith(origin)) {
|
||||
finalFilename = filename.substring(origin.length);
|
||||
}
|
||||
|
||||
frames.push({
|
||||
filename: finalFilename,
|
||||
function: fn,
|
||||
lineno: parseInt(lineNum, 10),
|
||||
colno: captureColumn ? parseInt(colNum, 10) : undefined,
|
||||
in_app: !isNodeModules,
|
||||
});
|
||||
} else {
|
||||
const v8Match = line.match(/^\s*at\s*(.+?)@(.+?):(\d+):(\d+)\s*$/);
|
||||
if (v8Match) {
|
||||
const [, fn, filename, lineNum, colNum] = v8Match;
|
||||
|
||||
const isNodeModules = filename.includes('node_modules');
|
||||
if (!captureNodeModules && isNodeModules) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let finalFilename = filename;
|
||||
if (relativePathOnly && origin && filename.startsWith(origin)) {
|
||||
finalFilename = filename.substring(origin.length);
|
||||
}
|
||||
|
||||
frames.push({
|
||||
filename: finalFilename,
|
||||
function: fn || '<anonymous>',
|
||||
lineno: parseInt(lineNum, 10),
|
||||
colno: captureColumn ? parseInt(colNum, 10) : undefined,
|
||||
in_app: !isNodeModules,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const urlMatch = line.match(/^\s*at\s*(.+?):(\d+):(\d+)\s*$/);
|
||||
if (urlMatch) {
|
||||
const [, filename, lineNum, colNum] = urlMatch;
|
||||
|
||||
const isNodeModules = filename.includes('node_modules');
|
||||
if (!captureNodeModules && isNodeModules) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let finalFilename = filename;
|
||||
if (relativePathOnly && origin && filename.startsWith(origin)) {
|
||||
finalFilename = filename.substring(origin.length);
|
||||
}
|
||||
|
||||
frames.push({
|
||||
filename: finalFilename,
|
||||
lineno: parseInt(lineNum, 10),
|
||||
colno: captureColumn ? parseInt(colNum, 10) : undefined,
|
||||
in_app: !isNodeModules,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reversed = frames.reverse();
|
||||
if (maxFrames !== undefined && maxFrames > 0) {
|
||||
return reversed.slice(0, maxFrames);
|
||||
}
|
||||
return reversed;
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
"target": "ES2018",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2018", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2019", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
|
|
|
|||
Loading…
Reference in New Issue