构建面向分片OpenSearch集群的Next.js高性能日志SDK实践


技术痛点:失控的前端日志风暴

我们维护着一个庞大的、由数十个Next.js应用组成的前端微服务体系。随着业务量的激增,前端日志的采集与分析逐渐成为一个瓶颈。最初采用的第三方日志服务,通过在每个页面加载一个重量级的JS脚本来采集数据,带来了几个无法容忍的问题:

  1. 性能阻塞: 其同步的初始化和数据上报逻辑,严重阻塞了浏览器主线程,直接影响了我们核心的Core Web Vitals指标,尤其是Interaction to Next Paint (INP)
  2. 请求风暴: 用户的每一次点击、每一次路由切换都可能产生一条日志请求。在高并发时段,这些海量的、碎片化的短连接请求对我们的API网关和后端的OpenSearch集群造成了巨大的冲击。
  3. 数据非结构化: 日志大多是纯文本字符串,难以在OpenSearch中进行有效的聚合与索引。这使得故障排查和用户行为分析变得异常低效。
  4. 成本失控: 按量付费的模式下,海量无用的心跳和调试日志导致成本飙升。

后端日志存储基于一个按tenant_id时间进行双重分片的超大规模OpenSearch集群。任何日志方案都必须与这个分片策略深度契合,否则查询性能将是一场灾难。我们需要的是一个为我们自己量身定做的解决方案:一个轻量、非阻塞、支持批量上报、强制结构化,并且能与后端分片策略紧密结合的前端日志SDK。

初步构想与技术选型决策

我们的目标是构建一个内部SDK,它应该像一个隐形的守护者,而不是一个笨拙的监视器。核心设计原则如下:

  • 异步与非阻塞: 绝不影响页面渲染和用户交互。所有操作都必须在后台进行。
  • 批量发送 (Batching): 在本地缓存日志,当达到一定数量或时间阈值时,合并成一个请求批量发送。
  • 结构化数据: SDK必须提供强类型的API,强制开发者以JSON格式记录日志,并自动附加必要的上下文信息(如tenant_id, user_id, page_url, trace_id等)。
  • 环境感知: SDK需要能区分client-sideserver-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)

为了应对不同场景,传输模块使用了两种策略:fetchnavigator.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.tsxlayout.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 ComponentsRoute 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');
}

遗留问题与未来迭代路径

这个方案解决了我们最初面临的核心痛点,但它并非完美。在真实项目中,总有可以改进的地方:

  1. Web Worker卸载: 当前的批处理逻辑仍在主线程中执行(尽管非常轻量)。对于需要进行复杂数据预处理的场景,可以考虑将整个日志队列和发送逻辑移至Web Worker中,实现与主线程的完全隔离。
  2. 动态采样: 对于某些高频事件(如mousemove),全量上报是不现实的。未来SDK可以引入动态采样机制,允许基于事件类型或配置,在客户端只上报一定比例的日志。
  3. 更复杂的重试与离线策略: 当前的错误处理很简单(丢弃日志)。一个更健壮的系统可以实现带指数退避的重试机制,甚至在使用IndexedDB等技术实现离线缓存,在网络恢复后重新发送。
  4. 服务端日志聚合: 在Server Components和Edge Functions中,每次请求都创建一个新的Logger实例可能会导致日志发送的效率不高。可以探索一个单例模式或一个集中的日志服务,来聚合来自同一服务器实例的日志。
  5. 与OpenTelemetry集成: 为了更宏大的可观测性目标,未来这个SDK可以作为OpenTelemetry的一个定制化Exporter,将我们的日志无缝接入到更广泛的分布式追踪体系中。

  目录