技术痛点:失控的前端日志风暴
我们维护着一个庞大的、由数十个Next.js应用组成的前端微服务体系。随着业务量的激增,前端日志的采集与分析逐渐成为一个瓶颈。最初采用的第三方日志服务,通过在每个页面加载一个重量级的JS脚本来采集数据,带来了几个无法容忍的问题:
- 性能阻塞: 其同步的初始化和数据上报逻辑,严重阻塞了浏览器主线程,直接影响了我们核心的Core Web Vitals指标,尤其是
Interaction to Next Paint (INP)
。 - 请求风暴: 用户的每一次点击、每一次路由切换都可能产生一条日志请求。在高并发时段,这些海量的、碎片化的短连接请求对我们的API网关和后端的OpenSearch集群造成了巨大的冲击。
- 数据非结构化: 日志大多是纯文本字符串,难以在OpenSearch中进行有效的聚合与索引。这使得故障排查和用户行为分析变得异常低效。
- 成本失控: 按量付费的模式下,海量无用的心跳和调试日志导致成本飙升。
后端日志存储基于一个按tenant_id
和时间
进行双重分片的超大规模OpenSearch集群。任何日志方案都必须与这个分片策略深度契合,否则查询性能将是一场灾难。我们需要的是一个为我们自己量身定做的解决方案:一个轻量、非阻塞、支持批量上报、强制结构化,并且能与后端分片策略紧密结合的前端日志SDK。
初步构想与技术选型决策
我们的目标是构建一个内部SDK,它应该像一个隐形的守护者,而不是一个笨拙的监视器。核心设计原则如下:
- 异步与非阻塞: 绝不影响页面渲染和用户交互。所有操作都必须在后台进行。
- 批量发送 (Batching): 在本地缓存日志,当达到一定数量或时间阈值时,合并成一个请求批量发送。
- 结构化数据: SDK必须提供强类型的API,强制开发者以JSON格式记录日志,并自动附加必要的上下文信息(如
tenant_id
,user_id
,page_url
,trace_id
等)。 - 环境感知: SDK需要能区分
client-side
和server-side
(包括React Server Components和Route Handlers)环境,并采用不同的策略。 - 轻量与Tree-Shakable: 最终打包产物必须足够小,并且允许应用在不使用某些功能(如
debug
级别的日志)时,通过打包工具将其完全移除。
基于这些原则,我们确定了技术栈:
- 核心打包工具 - Rollup: 为什么不是Webpack或Vite?因为我们的目标是构建一个库 (Library)**,而不是一个应用 (Application)**。Rollup专注于处理JavaScript库,它生成的代码更干净、更小,对ES Modules (ESM) 的支持和tree-shaking能力也优于其他工具。这正是我们所需要的。
- 目标框架 - Next.js: SDK必须原生支持Next.js的各种渲染模式。
- 后端目标 - OpenSearch: SDK输出的日志结构必须严格遵循OpenSearch的索引模板(Index Template),特别是要包含用于路由和分片的
tenant_id
字段。
步骤化实现:从零构建SDK
1. 项目结构与Rollup配置
我们先搭建一个标准的TypeScript库项目结构。
/
|- package.json
|- tsconfig.json
|- rollup.config.mjs
|- src/
| |- index.ts # SDK主入口
| |- logger.ts # 核心Logger类
| |- transport.ts # 数据发送模块
| |- types.ts # 类型定义
| |- utils.ts # 工具函数
rollup.config.mjs
是整个构建过程的核心。在真实项目中,一个健壮的配置远比教程里的要复杂。
// rollup.config.mjs
import typescript from '@rollup/plugin-typescript';
import { terser } from 'rollup-plugin-terser';
import dts from 'rollup-plugin-dts';
import pkg from './package.json' assert { type: 'json' };
const input = 'src/index.ts';
const external = [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
];
/**
* @type {import('rollup').RollupOptions[]}
*/
const config = [
// 1. 生成 CommonJS 和 ES Module 两种格式的产物
{
input,
output: [
{
file: pkg.main,
format: 'cjs',
sourcemap: true,
exports: 'named',
},
{
file: pkg.module,
format: 'esm',
sourcemap: true,
},
],
plugins: [
typescript({
tsconfig: './tsconfig.json',
sourceMap: true,
inlineSources: true,
}),
// 在生产环境中压缩代码
terser({
ecma: 2020,
mangle: { toplevel: true },
compress: {
module: true,
toplevel: true,
unsafe_arrows: true,
drop_console: process.env.NODE_ENV === 'production',
drop_debugger: process.env.NODE_ENV === 'production',
},
output: { quote_style: 1 },
}),
],
external,
},
// 2. 单独生成 .d.ts 类型声明文件
{
input,
output: [{ file: pkg.types, format: 'esm' }],
plugins: [dts()],
},
];
export default config;
配置解析:
- 双产物 (CJS/ESM): 我们同时生成
pkg.main
(CommonJS) 和pkg.module
(ES Module) 文件,以最大程度地兼容不同的Node.js和打包环境。Next.js会优先使用ESM版本以获得更好的tree-shaking效果。 -
dts()
插件: 这个插件至关重要,它会分析我们的TypeScript源码,并生成一个单一的、干净的类型声明文件 (index.d.ts
)。这使得SDK的使用者可以获得完整的类型提示。 -
terser()
: 用于代码压缩,我们只在production
构建时启用drop_console
,确保开发时日志不会被意外移除。 -
external
: 明确告诉Rollup哪些依赖(如react
)是外部的,不需要打包进来,而是由宿主应用(Next.js)提供。
2. 核心日志逻辑 (logger.ts
)
这是SDK的心脏。我们设计一个Logger
类,它内部维护一个日志队列和定时器。
// src/logger.ts
import { sendLogs } from './transport';
import type { LogEntry, LoggerConfig, LogLevel, CommonContext } from './types';
export class Logger {
private queue: LogEntry[] = [];
private timer: ReturnType<typeof setTimeout> | null = null;
private config: Required<LoggerConfig>;
private commonContext: CommonContext = {};
constructor(config: LoggerConfig) {
// 提供默认值,使配置更健壮
this.config = {
apiUrl: config.apiUrl,
batchSize: config.batchSize ?? 20,
flushInterval: config.flushInterval ?? 5000, // 5 seconds
logLevel: config.logLevel ?? 'info',
};
if (typeof window !== 'undefined') {
// 关键:监听页面卸载事件,确保在用户关闭页面前,将剩余日志全部发送出去
window.addEventListener('beforeunload', this.flush.bind(this));
}
}
// 设置贯穿所有日志的公共上下文,如 tenant_id
public setContext(context: CommonContext) {
this.commonContext = { ...this.commonContext, ...context };
}
public info<T extends object>(message: string, payload: T) {
this.log('info', message, payload);
}
public warn<T extends object>(message: string, payload: T) {
this.log('warn', message, payload);
}
public error<T extends object>(message: string, error: Error, payload?: T) {
// 对Error对象进行特殊处理,提取stack等信息
const errorInfo = {
name: error.name,
message: error.message,
stack: error.stack,
};
this.log('error', message, { ...payload, error: errorInfo });
}
// 核心的入队逻辑
private log<T extends object>(level: LogLevel, message: string, payload: T) {
// 单元测试思路:可以mock Date.now()来验证时间戳的正确性
const entry: LogEntry = {
timestamp: Date.now(),
level,
message,
context: this.commonContext,
payload,
// 自动附加客户端信息
meta: {
url: typeof window !== 'undefined' ? window.location.href : 'server-side',
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'server-side',
},
};
this.queue.push(entry);
// 如果队列达到批处理大小,立即发送
if (this.queue.length >= this.config.batchSize) {
this.flush();
} else if (!this.timer) {
// 否则,启动一个定时器,在指定间隔后发送
this.timer = setTimeout(() => {
this.flush();
}, this.config.flushInterval);
}
}
// 发送日志并清空队列
public flush() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
if (this.queue.length === 0) {
return;
}
const batch = this.queue.slice();
this.queue = [];
// 错误处理:即使发送失败,我们也不应该让它影响主应用
// 在真实项目中,这里可以加入重试逻辑(如指数退避)
try {
sendLogs(this.config.apiUrl, batch);
} catch (e) {
console.error('[LoggerSDK] Failed to send logs:', e);
// 失败后,可以选择将日志重新放回队列,或者丢弃
// 考虑到前端环境,通常选择丢弃,避免内存无限增长
}
}
}
3. 数据传输模块 (transport.ts
)
为了应对不同场景,传输模块使用了两种策略:fetch
和 navigator.sendBeacon
。
// src/transport.ts
import { LogEntry } from "./types";
/**
* 发送日志的核心函数
* @param apiUrl - The API endpoint to send logs to.
* @param logs - An array of log entries.
*/
export function sendLogs(apiUrl: string, logs: LogEntry[]) {
const body = JSON.stringify({ logs });
// 关键优化:在现代浏览器中,如果支持sendBeacon,且在浏览器环境中,优先使用它。
// sendBeacon 专门用于发送分析数据,它会异步在后台发送,
// 并且即使页面正在卸载(unload)也能保证请求发出,不会阻塞页面关闭。
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
const success = navigator.sendBeacon(apiUrl, body);
if (!success) {
// sendBeacon有大小限制(通常64KB),如果失败,回退到fetch
console.warn('[LoggerSDK] sendBeacon failed, falling back to fetch.');
fallbackFetch(apiUrl, body);
}
} else {
// 在不支持sendBeacon的环境(如Node.js - SSR/RSC)或回退时,使用fetch
fallbackFetch(apiUrl, body);
}
}
function fallbackFetch(apiUrl: string, body: string) {
// 使用 keepalive 选项,这对于在页面卸载期间发送请求至关重要。
// 它告诉浏览器即使页面关闭,也要保持连接直到请求完成。
fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
keepalive: true,
}).catch(error => {
// 日志发送失败不应是关键路径,只在控制台打印错误
console.error('[LoggerSDK] Error sending logs via fetch:', error);
});
}
这个模块的设计体现了生产级代码的考量:
- 优雅降级: 优先使用
sendBeacon
,因为它就是为此类场景设计的。当它失败(如数据包过大)或不可用时,无缝回退到fetch
。 -
keepalive: true
: 这是fetch
的一个关键选项,它实现了类似sendBeacon
的效果,允许请求在页面关闭后继续进行,极大地提高了日志发送的成功率。
4. 可视化架构流程
整个日志流转过程可以用下面的图来表示:
sequenceDiagram participant Next.js App as App participant LoggerSDK as SDK participant Browser as Browser participant LogAPI as API participant OpenSearch as OS App->>SDK: logger.info("User clicked", { button: "buy" }) SDK-->>SDK: Add log to internal queue alt Queue size >= batchSize SDK-->>SDK: Immediately trigger flush() else Timer not running SDK-->>Browser: setTimeout(flush, interval) end Note over SDK: User closes the page Browser->>SDK: Trigger 'beforeunload' event SDK-->>SDK: Immediately trigger flush() SDK->>Browser: sendBeacon(API_URL, batch) or fetch(..., {keepalive:true}) Browser-->>LogAPI: POST /logs with JSON payload LogAPI-->>LogAPI: Validate and enrich data LogAPI->>OS: Bulk index documents Note over OS: OpenSearch routes documents based on
tenant_id and timestamp to correct shards OS-->>LogAPI: Indexing confirmation LogAPI-->>Browser: 200 OK
5. 在Next.js中的集成
集成是体现SDK易用性的关键。我们使用React Context来提供一个全局唯一的logger实例。
// src/react/provider.tsx
import React, { createContext, useContext, useMemo } from 'react';
import { Logger } from '../logger';
import { LoggerConfig } from '../types';
const LoggerContext = createContext<Logger | null>(null);
export const LoggerProvider: React.FC<{
config: LoggerConfig;
children: React.ReactNode;
}> = ({ config, children }) => {
// 使用 useMemo 确保 logger 实例在整个应用生命周期中只被创建一次
const logger = useMemo(() => new Logger(config), [config]);
// 在组件卸载时,确保所有日志都被发送
// 虽然logger内部有beforeunload监听,但这是一个双重保障
React.useEffect(() => {
return () => {
logger.flush();
};
}, [logger]);
return <LoggerContext.Provider value={logger}>{children}</LoggerContext.Provider>;
};
export const useLogger = (): Logger => {
const context = useContext(LoggerContext);
if (!context) {
throw new Error('useLogger must be used within a LoggerProvider');
}
return context;
};
在Next.js的 _app.tsx
或 layout.tsx
中使用:
// app/layout.tsx
'use client';
import { LoggerProvider } from 'my-awesome-log-sdk/react';
import { useSession } from 'next-auth/react'; // 假设使用next-auth获取用户信息
export default function RootLayout({ children }: { children: React.ReactNode }) {
const { data: session } = useSession();
// 配置动态化,从环境变量读取API地址
const loggerConfig = {
apiUrl: process.env.NEXT_PUBLIC_LOG_API_URL!,
batchSize: 50,
flushInterval: 10000,
};
return (
<html lang="en">
<body>
<LoggerProvider config={loggerConfig}>
{/* 使用一个组件在获取到session后设置全局上下文 */}
<LoggerContextSetter tenantId={session?.user?.tenantId} />
{children}
</LoggerProvider>
</body>
</html>
);
}
// 一个辅助组件,用于在上下文可用时设置Logger
function LoggerContextSetter({ tenantId }: { tenantId?: string }) {
const logger = useLogger();
React.useEffect(() => {
if (tenantId) {
// 关键:在这里设置 tenant_id,它将自动附加到每一条后续日志中
// 这直接影响后端的OpenSearch分片路由
logger.setContext({ tenant_id: tenantId });
}
}, [tenantId, logger]);
return null;
}
在任何客户端组件中使用:
'use client';
import { useLogger } from 'my-awesome-log-sdk/react';
function MyButton() {
const logger = useLogger();
const handleClick = () => {
logger.info('Purchase button clicked', { productId: 'abc-123', price: 99.99 });
// ...业务逻辑
};
return <button onClick={handleClick}>Buy Now</button>;
}
对于React Server Components 或 Route Handlers,由于没有React Context,我们需要直接实例化和使用:
// app/api/some-action/route.ts
import { Logger } from 'my-awesome-log-sdk';
export async function POST(request: Request) {
const logger = new Logger({ apiUrl: process.env.LOG_API_URL! });
const session = await getServerSession(); // 获取服务端session
// 在服务端,我们通常希望日志立即发送,而不是批处理
// 因此,直接设置上下文并调用 flush 是一种常见模式
logger.setContext({ tenant_id: session?.user.tenantId, source: 'route-handler' });
try {
// ...业务逻辑
logger.info('Action executed successfully', { some: 'data' });
} catch (error) {
logger.error('Action failed', error as Error);
} finally {
// 服务端是无状态的,请求结束前必须确保日志已发送
logger.flush();
}
return new Response('OK');
}
遗留问题与未来迭代路径
这个方案解决了我们最初面临的核心痛点,但它并非完美。在真实项目中,总有可以改进的地方:
- Web Worker卸载: 当前的批处理逻辑仍在主线程中执行(尽管非常轻量)。对于需要进行复杂数据预处理的场景,可以考虑将整个日志队列和发送逻辑移至Web Worker中,实现与主线程的完全隔离。
- 动态采样: 对于某些高频事件(如
mousemove
),全量上报是不现实的。未来SDK可以引入动态采样机制,允许基于事件类型或配置,在客户端只上报一定比例的日志。 - 更复杂的重试与离线策略: 当前的错误处理很简单(丢弃日志)。一个更健壮的系统可以实现带指数退避的重试机制,甚至在使用IndexedDB等技术实现离线缓存,在网络恢复后重新发送。
- 服务端日志聚合: 在Server Components和Edge Functions中,每次请求都创建一个新的Logger实例可能会导致日志发送的效率不高。可以探索一个单例模式或一个集中的日志服务,来聚合来自同一服务器实例的日志。
- 与OpenTelemetry集成: 为了更宏大的可观测性目标,未来这个SDK可以作为OpenTelemetry的一个定制化Exporter,将我们的日志无缝接入到更广泛的分布式追踪体系中。