feat: 新增功能

This commit is contained in:
weidingjian 2026-06-24 23:59:28 +08:00
parent c8ab7ca010
commit fc95159945
43 changed files with 6654 additions and 446 deletions

View File

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

View File

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

View File

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

View File

@ -35,7 +35,6 @@ export declare class Reporter {
*/
private stripSharedFields;
private getEnvelopeType;
private generateEventId;
private send;
private sendSync;
private sendViaImage;

7
dist/index.cjs.js vendored

File diff suppressed because one or more lines are too long

3
dist/index.d.ts vendored
View File

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

7
dist/index.esm.js vendored

File diff suppressed because one or more lines are too long

7
dist/index.iife.js vendored

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

15
dist/plugins/SamplingPlugin.d.ts vendored Normal file
View File

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

20
dist/types/index.d.ts vendored
View File

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

View File

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

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

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

227
docs/00-architecture.md Normal file
View File

@ -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 # 部署与运维设计
```

1245
docs/01-sdk-design.md Normal file

File diff suppressed because it is too large Load Diff

492
docs/02-api-server.md Normal file
View File

@ -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 认证头(备用方式) | 否 |
#### 认证方式(二选一)
**方式 1URL 参数(推荐,兼容性好)**
```
?sentry_key={publicKey}&sentry_version=7
```
**方式 2Header 方式**
```
X-Sentry-Auth: Sentry sentry_version=7, sentry_key={publicKey}
```
#### Envelope 格式解析
标准 Envelope 格式:
```
# 第 1 行envelope headerJSON
{"event_id":"abc123","sent_at":"2024-01-01T00:00:00Z"}
# 第 2 行item headerJSON
{"type":"event","length":123}
# 第 3 行item payloadJSON长度 = 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不推荐太重了

533
docs/03-data-aggregation.md Normal file
View File

@ -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 | 内部 IDproj_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 演进路径
**阶段 1JSON → SQLite单机版**
- 零依赖,开箱即用
- 适合个人项目、小团队
- 单节点足够
**阶段 2SQLite → 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 版本管理

451
docs/04-alert-engine.md Normal file
View File

@ -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. 告警抑制和升级

916
docs/05-admin-dashboard.md Normal file
View File

@ -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 Bar64px
│ ┌──────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ 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 支持好
- 支持 middlewarepersist、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 操作反馈
- 重要操作(删除、标记已解决)有确认弹窗
- 成功操作顶部弹 toast3 秒自动消失
- 操作失败显示错误原因,可重试
---
## 九、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**
- 完整的错误管理工作流
- 性能深入分析(瀑布图、资源时序)
- 用户会话追踪

544
docs/06-deployment.md Normal file
View File

@ -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;
# CORSSDK 上报需要)
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 数据迁移
- 配置向后兼容,新版本能读旧版本数据
- 启动时自动检测并执行数据迁移
- 迁移前自动备份

577
docs/ARCHITECTURE.md Normal file
View File

@ -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**
- 轻量级,内存占用远低于 ELKElasticsearch 需要至少 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 资源受限环境

658
docs/INTEGRATION.md Normal file
View File

@ -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 的 **DSNData 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 接入
**ReactCreate 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.110%
- 每条 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',
});
```

View File

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

View File

@ -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) {
// 创建浅拷贝用于裁剪
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 event.exception.stacktrace;
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) {
if (event.breadcrumbs && Array.isArray(event.breadcrumbs)) {
if (levelConfig.maxBreadcrumbs === 0) {
// info/debug 级别不需要 breadcrumbs
delete event.breadcrumbs;
delete enriched.breadcrumbs;
} else {
enriched.breadcrumbs = (event.breadcrumbs as Breadcrumb[]).slice(-levelConfig.maxBreadcrumbs);
}
}
return enriched;
}
}

View File

@ -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 {
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
this.onVisibilityChange = () => {
if (document.hidden && (this.queue.length > 0 || this.pendingCounts.size > 0)) {
this.flushSync();
}
});
};
if (typeof document !== 'undefined') {
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 = {};

View File

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

View File

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

View File

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

View File

@ -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;
destroy(): void {
if (typeof window !== 'undefined') {
if (this.originalOnError !== undefined) {
window.onerror = this.originalOnError;
}
let finalFilename = filename;
if (this.config.relativePathOnly && origin && filename.startsWith(origin)) {
finalFilename = filename.substring(origin.length);
if (this.onUnhandledRejection) {
window.removeEventListener('unhandledrejection', this.onUnhandledRejection);
}
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,
});
if (this.onResourceError) {
window.removeEventListener('error', this.onResourceError, true);
}
}
}
return frames.reverse();
}
destroy(): void {}
}
export default ErrorPlugin;

View File

@ -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,9 +47,13 @@ class NetworkPlugin implements LightPlugin {
setup(client: LightClient): void {
this.client = client;
this.loadConfig();
if (this.config.captureFetch) {
this.patchFetch();
}
if (this.config.captureXHR) {
this.patchXHR();
}
}
private loadConfig(): void {
const clientConfig = this.client.config as LightConfig;
@ -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,37 +167,28 @@ 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;
}
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');
@ -213,12 +213,13 @@ class NetworkPlugin implements LightPlugin {
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>);
};
}

View File

@ -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,
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();
if (storedEvents.length === 0) {
console.info('[LightSDK] OfflinePlugin: No pending events to sync');
this.isSyncing = false;
return;
}
// 分批上报,每次最多 50 条
const batchSize = 50;
for (let i = 0; i < storedEvents.length; i += batchSize) {
const batch = storedEvents.slice(i, i + batchSize);
const events = batch.map(e => e.event);
let syncedCount = 0;
// 先处理内存中的事件
while (this.pendingEvents.length > 0) {
const event = this.pendingEvents.shift() as Partial<SentryEvent> & { type: string };
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`);
this.client.captureEvent(event);
syncedCount++;
} catch (error) {
console.error('[LightSDK] OfflinePlugin: Failed to sync batch', error);
// 继续同步其他批次
console.error('[LightSDK] OfflinePlugin: Failed to re-enqueue memory event', 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

100
src/utils/stacktrace.ts Normal file
View File

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

View File

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