日志难追踪怎么办?用JavaScript实现前端日志收集系统

各位前端领域的同仁们,大家好!

今天,我们将共同探讨一个在日常开发与维护中常常令人头疼,但又至关重要的议题:前端日志的追踪与管理。你是否曾经历过用户反馈了一个难以复现的Bug,却苦于没有足够的现场信息而无从下手?你是否曾面对线上应用突发的性能问题,却不知道是哪段代码或哪个用户操作导致了瓶颈?当后端日志无法触及用户浏览器这一“最后一公里”的真实情况时,我们该如何破局?

答案便是:构建一个强大的前端日志收集系统。

在本场讲座中,我将作为一名编程专家,带领大家深入理解前端日志收集的必要性、核心概念,并通过JavaScript亲手实现一个功能完善、健壮可靠的前端日志收集系统。我们将从零开始,逐步构建日志的核心模块、错误捕获机制、用户行为追踪、性能数据采集,并探讨数据传输、存储、隐私与安全等高级话题。

准备好了吗?让我们一起开启这段技术探索之旅。


一、为什么前端日志如此重要?前端监控的“最后一公里”

在现代Web应用,特别是单页应用(SPA)和复杂交互式界面的时代,前端不再仅仅是展示数据的“瘦客户端”,它承载了大量的业务逻辑、用户交互和状态管理。这意味着,许多问题——从细微的UI偏差到导致应用崩溃的致命错误——都可能发生在用户的浏览器环境中。

传统的后端日志系统固然重要,它们能记录服务器端的请求、处理逻辑、数据库操作等。但它们无法回答以下这些关键问题:

  1. 用户到底做了什么? 是点击了某个按钮,填写了某个表单,还是进行了特定的导航路径,才导致了问题?
  2. 前端代码执行时发生了什么错误? 是JavaScript运行时错误、Promise拒绝、资源加载失败,还是某个组件渲染异常?
  3. 用户的设备和网络环境如何? 是在低速网络下加载缓慢,还是在特定浏览器版本下出现了兼容性问题?屏幕尺寸、操作系统版本等信息对调试至关重要。
  4. 前端性能表现如何? 页面首次内容绘制时间(FCP)、最大内容绘制时间(LCP)、首次输入延迟(FID)等核心指标是否达标?

缺乏前端日志,我们就像在黑暗中摸索,只能根据用户模糊的描述和后端有限的信息进行猜测,效率低下且往往无法彻底解决问题。一个完善的前端日志收集系统,就是我们洞察用户真实体验、定位问题根源、优化应用性能的“眼睛”和“耳朵”。

它将帮助我们将被动响应转变为主动监控,从“用户反馈了,我再看”变为“问题发生了,我已知道并正在解决”。


二、前端日志系统的核心概念与设计原则

在着手实现之前,我们首先需要明确前端日志系统的核心构成要素和设计原则。

2.1 日志的类型与内容

一个全面的前端日志系统应该能够收集以下几类信息:

  1. 错误日志 (Error Logs):

    • JavaScript运行时错误(Uncaught TypeError, ReferenceError等)。
    • 未处理的Promise拒绝(Unhandled Promise Rejection)。
    • 资源加载失败(图片、CSS、JavaScript文件加载失败)。
    • API请求失败(HTTP状态码非2xx)。
    • 自定义业务错误(例如,表单校验失败、业务逻辑异常)。
    • 错误堆栈、错误信息、发生错误的URL、行号列号等是必备信息。
  2. 行为日志 (Behavior Logs):

    • 页面访问与路由切换(用户访问了哪些页面,切换路径)。
    • 用户点击事件(点击了哪个按钮、链接,触发了什么操作)。
    • 表单输入与提交(对敏感信息需脱敏处理)。
    • 关键业务操作(例如,商品加入购物车、支付成功、文件上传等)。
    • 记录事件类型、目标元素、相关数据等。
  3. 性能日志 (Performance Logs):

    • 页面加载性能(FCP, LCP, FID, CLS等核心Web Vitals指标)。
    • 自定义性能指标(例如,某个组件的渲染时间、API请求耗时)。
    • 资源加载瀑布图(虽然全量收集不现实,但可记录关键资源耗时)。
    • 记录指标名称、值、发生时间等。
  4. 调试与信息日志 (Debug & Info Logs):

    • 应用初始化时的关键信息(版本号、环境信息)。
    • 重要模块的生命周期事件。
    • 复杂业务逻辑的执行路径。
    • 通常在开发或测试环境使用,生产环境可关闭或降低级别。
  5. 环境信息 (Environment Context):

    • 用户标识: 用户ID、会话ID、匿名ID。
    • 设备信息: 浏览器类型、版本、操作系统、屏幕分辨率。
    • 网络信息: 连接类型(WiFi, 4G)、网络状态。
    • 应用信息: 应用版本、当前页面URL。
    • 这些上下文信息对于理解问题发生的背景至关重要。

2.2 日志级别 (Log Levels)

为了区分日志的重要性,我们通常会定义不同的日志级别。常见的级别包括:

级别 描述 用途
DEBUG 最详细的日志信息,通常只在开发环境使用。 调试复杂逻辑,追踪变量状态。
INFO 提供应用运行时的重要事件信息。 记录用户操作、应用启动、重要业务流程。
WARN 潜在的问题,不影响应用正常运行,但值得关注。 资源加载失败(非关键)、弃用警告、非预期但可恢复的情况。
ERROR 运行时错误,可能导致功能异常,但应用未崩溃。 JavaScript运行时错误、API请求失败、自定义业务错误。
CRITICAL 致命错误,导致应用无法继续运行或严重数据损坏。 极少使用,通常由更高级别的监控系统触发,前端层面多由ERROR覆盖。

通过配置不同的日志级别,我们可以在生产环境中只收集WARNERROR级别以上的日志,而在开发环境中打开DEBUGINFO级别,以减少日志量并提高性能。

2.3 日志数据结构

每条日志都应该是一个结构化的数据对象,包含以下基本字段:

{
  "timestamp": "2023-10-27T10:30:00.123Z", // UTC时间戳
  "level": "ERROR",                       // 日志级别
  "message": "Uncaught TypeError: Cannot read property 'foo' of undefined", // 日志信息
  "userId": "user-123",                   // 用户ID
  "sessionId": "sess-abc",                // 会话ID
  "appVersion": "1.0.0",                  // 应用版本
  "browser": {                            // 浏览器信息
    "name": "Chrome",
    "version": "118.0.0.0",
    "userAgent": "Mozilla/5.0..."
  },
  "os": {                                 // 操作系统信息
    "name": "Windows",
    "version": "10"
  },
  "url": "https://example.com/some/path", // 当前页面URL
  "referrer": "https://example.com/prev", // 来源URL
  "stack": "TypeError: ...n    at ...", // 错误堆栈
  "context": {                            // 额外上下文信息
    "componentName": "UserProfile",
    "dataId": "456",
    "apiEndpoint": "/api/user/profile"
  }
}

结构化日志的优点在于方便后端存储、查询和分析。

2.4 日志的传输与存储策略

前端日志的传输与存储需要考虑以下几点:

  1. 异步传输: 日志收集是一个IO密集型操作,应尽量避免阻塞主线程。
  2. 批量发送: 单条发送日志会产生大量的网络请求,增加服务器和客户端的负担。应将多条日志聚合成批次发送。
  3. 持久化: 在用户刷新页面、网络中断等情况下,未发送的日志不应丢失。
  4. 传输协议: 通常使用HTTP POST请求发送JSON数据。在页面卸载时,navigator.sendBeacon是一个更可靠的选择。
  5. 后端接收: 后端需要提供一个API接口来接收前端发送的日志,并将其存储到日志管理系统(如ELK Stack、Splunk、Datadog等)或数据库中。

2.5 隐私与安全

在收集用户数据时,隐私和安全是不可忽视的红线:

  1. 数据脱敏/匿名化: 严格禁止收集用户的敏感个人信息(PII),如身份证号、银行卡号、密码、真实姓名、手机号等。如果必须收集,则需进行严格的脱敏处理。
  2. 最小化原则: 只收集解决问题所必需的信息,避免过度收集。
  3. HTTPS: 确保日志传输全程使用HTTPS,防止数据在传输过程中被窃取或篡改。
  4. 用户同意: 在某些地区(如欧盟的GDPR),可能需要明确征得用户的同意才能收集日志。

三、构建前端日志收集系统:核心模块实现

现在,我们开始进入激动人心的代码实现环节。我们将分步构建系统的各个核心模块。

3.1 基础日志器 (Base Logger)

我们首先创建一个Logger类,它将提供不同日志级别的方法,并负责将日志条目标准化、存储到缓冲区。

// logger.js

/**
 * @class LoggerConfig
 * @property {string} level - 默认日志级别,低于此级别的日志将不被处理。
 * @property {string} appVersion - 应用版本号。
 * @property {boolean} enableConsole - 是否在控制台打印日志。
 */
class Logger {
    /**
     * @param {object} config - 配置对象。
     * @param {LogSender} logSender - 日志发送器实例。
     */
    constructor(config = {}, logSender) {
        this.config = {
            level: 'INFO', // 默认日志级别
            appVersion: 'unknown',
            enableConsole: true, // 默认在控制台打印
            // 其他配置...
            ...config
        };

        if (!logSender) {
            console.warn('Logger initialized without a LogSender. Logs will only be consoled.');
        }
        this.logSender = logSender; // 注入日志发送器

        this.levels = {
            DEBUG: 0,
            INFO: 1,
            WARN: 2,
            ERROR: 3,
            CRITICAL: 4
        };

        // 绑定所有日志方法到当前实例,确保this指向正确
        for (const levelName in this.levels) {
            this[levelName.toLowerCase()] = this._log.bind(this, levelName);
        }
    }

    /**
     * 核心日志记录方法
     * @param {string} level - 日志级别 (DEBUG, INFO, WARN, ERROR, CRITICAL)
     * @param {string} message - 日志信息
     * @param {object} [context={}] - 额外上下文数据
     */
    _log(level, message, context = {}) {
        // 判断当前日志级别是否低于配置的最低级别,如果是则不处理
        if (this.levels[level] < this.levels[this.config.level]) {
            return;
        }

        // 构建标准化日志条目
        const logEntry = {
            timestamp: new Date().toISOString(),
            level: level,
            message: message,
            appVersion: this.config.appVersion,
            context: context,
            // 稍后会由 LogSender 补充更多环境信息
        };

        // 如果启用控制台打印,则打印到控制台
        if (this.config.enableConsole) {
            this._consoleLog(level, message, context);
        }

        // 将日志添加到发送器的缓冲区
        if (this.logSender) {
            this.logSender.add(logEntry);
        }
    }

    /**
     * 在控制台打印日志
     * @param {string} level - 日志级别
     * @param {string} message - 日志信息
     * @param {object} context - 额外上下文数据
     */
    _consoleLog(level, message, context) {
        const consoleMethod = level.toLowerCase();
        if (typeof console[consoleMethod] === 'function') {
            console[consoleMethod](`[${level}] ${message}`, context);
        } else {
            // 对于CRITICAL等没有直接对应console方法的,使用error或log
            console.log(`[${level}] ${message}`, context);
        }
    }
}

// 导出 Logger 类
export { Logger };

代码解析:

  • Logger类构造函数接收一个配置对象和一个logSender实例。logSender将负责日志的实际传输,这里我们通过依赖注入的方式传入。
  • levels对象定义了日志级别的优先级,用于过滤低于设定阈值的日志。
  • _log是核心方法,它接收日志级别、消息和上下文信息,构建一个标准化的日志对象。
  • _consoleLog负责将日志打印到浏览器控制台,方便开发调试。
  • 我们通过循环动态创建了debug, info, warn, error, critical等方法,并使用bind确保this指向Logger实例。

3.2 用户与环境上下文 (User and Environment Context)

为了让每条日志都携带足够的信息,我们需要一个模块来收集当前用户和运行环境的数据。

// userContext.js

/**
 * @class UserContext
 * 负责收集用户的唯一标识、会话信息以及当前运行环境(浏览器、操作系统、URL等)。
 */
class UserContext {
    constructor() {
        this._userId = this._getUserId();
        this._sessionId = this._getSessionId();
        this._userAgent = navigator.userAgent;
        this._browserInfo = this._parseUserAgent(this._userAgent);
        this._osInfo = this._parseOS(this._userAgent);
        this._viewport = `${window.innerWidth}x${window.innerHeight}`;
        this._currentUrl = window.location.href;
        this._referrer = document.referrer;
        this._networkType = this._getNetworkType();
    }

    /**
     * 从 localStorage 或 cookie 获取用户ID,如果不存在则生成一个匿名ID。
     * @returns {string} 用户ID。
     * @private
     */
    _getUserId() {
        let userId = localStorage.getItem('app_user_id');
        if (!userId) {
            userId = `anonymous_${Math.random().toString(36).substring(2, 12)}`;
            localStorage.setItem('app_user_id', userId);
        }
        return userId;
    }

    /**
     * 从 sessionStorage 获取会话ID,如果不存在则生成一个新的会话ID。
     * @returns {string} 会话ID。
     * @private
     */
    _getSessionId() {
        let sessionId = sessionStorage.getItem('app_session_id');
        if (!sessionId) {
            sessionId = `${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
            sessionStorage.setItem('app_session_id', sessionId);
        }
        return sessionId;
    }

    /**
     * 解析 User-Agent 字符串以获取浏览器信息。
     * 这是一个简化的实现,实际应用中可能需要更健壮的库(如 ua-parser-js)。
     * @param {string} userAgent - navigator.userAgent 字符串。
     * @returns {object} 包含 name 和 version 的浏览器信息。
     * @private
     */
    _parseUserAgent(userAgent) {
        const browser = {};
        let match;

        if ((match = userAgent.match(/(firefox)/([d.]+)/i))) {
            browser.name = match[1];
            browser.version = match[2];
        } else if ((match = userAgent.match(/(chrome|crios)/([d.]+)/i))) {
            browser.name = 'Chrome';
            browser.version = match[2];
        } else if ((match = userAgent.match(/(safari)/([d.]+)/i))) {
            browser.name = 'Safari';
            browser.version = match[2];
        } else if ((match = userAgent.match(/(edge)/([d.]+)/i))) {
            browser.name = 'Edge';
            browser.version = match[2];
        } else if ((match = userAgent.match(/(msie |rv:)(d+.d+)/i))) {
            browser.name = 'IE';
            browser.version = match[2];
        } else {
            browser.name = 'Unknown';
            browser.version = 'Unknown';
        }
        return browser;
    }

    /**
     * 解析 User-Agent 字符串以获取操作系统信息。
     * 这是一个简化的实现。
     * @param {string} userAgent - navigator.userAgent 字符串。
     * @returns {object} 包含 name 和 version 的操作系统信息。
     * @private
     */
    _parseOS(userAgent) {
        const os = {};
        if (/windows nt (d+.d+)/i.test(userAgent)) {
            os.name = 'Windows';
            const version = parseFloat(RegExp.$1);
            if (version >= 10.0) os.version = '10';
            else if (version >= 6.2) os.version = '8';
            else if (version >= 6.1) os.version = '7';
            else os.version = 'XP/Vista';
        } else if (/mac os x (d+)_(d+)_?(d+)?/i.test(userAgent)) {
            os.name = 'macOS';
            os.version = `${RegExp.$1}.${RegExp.$2}.${RegExp.$3 || '0'}`;
        } else if (/android (d+.d+)/i.test(userAgent)) {
            os.name = 'Android';
            os.version = RegExp.$1;
        } else if (/iphone|ipad|ipod/i.test(userAgent)) {
            os.name = 'iOS';
            os.version = /OS (d+)_(d+)_?(d+)?/i.test(userAgent) ? `${RegExp.$1}.${RegExp.$2}.${RegExp.$3 || '0'}` : 'Unknown';
        } else if (/linux/i.test(userAgent)) {
            os.name = 'Linux';
            os.version = 'Unknown';
        } else {
            os.name = 'Unknown';
            os.version = 'Unknown';
        }
        return os;
    }

    /**
     * 获取网络连接类型。
     * @returns {string} 网络类型。
     * @private
     */
    _getNetworkType() {
        if (navigator.connection && navigator.connection.effectiveType) {
            return navigator.connection.effectiveType;
        }
        return 'unknown';
    }

    /**
     * 更新当前页面的URL(通常在路由切换时调用)。
     * @param {string} url - 新的URL。
     */
    updateCurrentUrl(url) {
        this._currentUrl = url;
    }

    /**
     * 获取所有上下文信息。
     * @returns {object} 包含所有用户和环境信息的对象。
     */
    getContext() {
        return {
            userId: this._userId,
            sessionId: this._sessionId,
            userAgent: this._userAgent,
            browser: this._browserInfo,
            os: this._osInfo,
            viewport: this._viewport,
            currentUrl: this._currentUrl,
            referrer: this._referrer,
            networkType: this._networkType,
            // 更多信息可以根据需要添加,例如屏幕像素比、语言等
            // devicePixelRatio: window.devicePixelRatio,
            // language: navigator.language,
        };
    }
}

export { UserContext };

代码解析:

  • UserContext类在构造函数中收集了大量的环境信息。
  • _getUserId_getSessionId方法分别从localStoragesessionStorage中获取或生成唯一的ID,以确保用户和会话的连续性。
  • _parseUserAgent_parseOS是简化的用户代理字符串解析器,用于识别浏览器和操作系统。在实际项目中,可以考虑使用更成熟的第三方库(如ua-parser-js)来获取更精确的信息。
  • _getNetworkType利用navigator.connectionAPI获取网络类型。
  • updateCurrentUrl方法允许外部更新当前页面的URL,这对于SPA的路由追踪非常重要。
  • getContext方法返回一个包含所有收集到信息的对象,供日志发送器在发送日志时附加到每个日志条目上。

3.3 日志发送器 (Log Sender)

日志发送器负责缓冲日志、批量发送,并处理网络请求。

// logSender.js

/**
 * @class LogSenderConfig
 * @property {string} sendUrl - 日志发送的后端API地址。
 * @property {number} batchSize - 每次发送的日志条目数量。
 * @property {number} sendInterval - 定时发送日志的间隔时间(毫秒)。如果为0,则只在缓冲区满或页面卸载时发送。
 * @property {number} maxBufferSize - 本地缓冲区最大日志条目数,防止内存溢出。
 * @property {string} storageKey - 用于 localStorage 存储日志缓冲区的键名。
 */
class LogSender {
    /**
     * @param {object} config - 配置对象。
     * @param {UserContext} userContext - 用户上下文实例。
     */
    constructor(config = {}, userContext) {
        this.config = {
            sendUrl: '/api/logs',
            batchSize: 10,
            sendInterval: 5000, // 5秒发送一次
            maxBufferSize: 1000,
            storageKey: 'frontend_log_buffer',
            ...config
        };

        this.buffer = []; // 内存缓冲区
        this.userContext = userContext; // 注入用户上下文
        this.timer = null; // 定时器句柄

        this._loadBufferFromStorage(); // 初始化时从 localStorage 加载未发送的日志
        this._initTimer(); // 启动定时发送机制
        this._setupUnloadHandler(); // 注册页面卸载事件处理器
    }

    /**
     * 从 localStorage 加载之前未发送的日志。
     * @private
     */
    _loadBufferFromStorage() {
        try {
            const storedBuffer = localStorage.getItem(this.config.storageKey);
            if (storedBuffer) {
                this.buffer = JSON.parse(storedBuffer);
                // 限制加载的缓冲区大小,防止旧的、过大的数据影响应用
                if (this.buffer.length > this.config.maxBufferSize) {
                    this.buffer = this.buffer.slice(this.buffer.length - this.config.maxBufferSize);
                }
                console.log(`Loaded ${this.buffer.length} logs from localStorage.`);
            }
        } catch (e) {
            console.error('Failed to load log buffer from localStorage:', e);
            this.buffer = []; // 加载失败,清空缓冲区
        }
    }

    /**
     * 将当前内存缓冲区中的日志保存到 localStorage。
     * @private
     */
    _saveBufferToStorage() {
        try {
            // 只保存最新的 maxBufferSize 条日志
            const logsToSave = this.buffer.slice(-this.config.maxBufferSize);
            localStorage.setItem(this.config.storageKey, JSON.stringify(logsToSave));
        } catch (e) {
            console.error('Failed to save log buffer to localStorage:', e);
            // 如果存储失败,尝试清空,避免持续错误
            if (e.name === 'QuotaExceededError') {
                console.warn('LocalStorage quota exceeded, clearing log buffer in storage.');
                localStorage.removeItem(this.config.storageKey);
            }
        }
    }

    /**
     * 初始化定时发送器。
     * @private
     */
    _initTimer() {
        if (this.config.sendInterval > 0) {
            this.timer = setInterval(() => this.sendLogs(), this.config.sendInterval);
        }
    }

    /**
     * 注册页面卸载事件处理器,确保在页面关闭前发送所有待发送日志。
     * @private
     */
    _setupUnloadHandler() {
        // 使用 beforeunload 和 unload 事件确保在页面关闭时尽可能发送日志
        window.addEventListener('beforeunload', () => {
            this.sendLogs(true); // 标记为卸载时发送
        });
        // 某些浏览器可能不支持 beforeunload 的异步操作,unolad 也能提供一些保障
        window.addEventListener('unload', () => {
            this.sendLogs(true);
        });
    }

    /**
     * 向缓冲区添加一条日志。
     * @param {object} logEntry - 待添加的日志条目。
     */
    add(logEntry) {
        // 附加用户和环境上下文信息
        const enrichedEntry = {
            ...logEntry,
            ...this.userContext.getContext(), // 合并所有上下文信息
        };
        this.buffer.push(enrichedEntry);

        // 如果缓冲区超过最大限制,移除最旧的日志
        if (this.buffer.length > this.config.maxBufferSize) {
            this.buffer.shift();
        }

        this._saveBufferToStorage(); // 每次添加都持久化,增强鲁棒性

        // 如果缓冲区达到批量发送阈值,立即发送
        if (this.buffer.length >= this.config.batchSize) {
            this.sendLogs();
        }
    }

    /**
     * 发送缓冲区中的日志。
     * @param {boolean} [isUnload=false] - 是否在页面卸载时发送。
     */
    async sendLogs(isUnload = false) {
        if (this.buffer.length === 0) {
            return;
        }

        // 取出当前缓冲区的所有日志进行发送
        const logsToSend = this.buffer.splice(0, this.buffer.length);
        this._saveBufferToStorage(); // 清空 localStorage 中的缓冲区

        try {
            const payload = JSON.stringify(logsToSend);

            if (isUnload && navigator.sendBeacon) {
                // 在页面卸载时,优先使用 sendBeacon API,它保证请求在页面卸载后继续发送,不阻塞主线程。
                // sendBeacon 只能发送 ArrayBufferView, Blob, DOMString, FormData 类型的数据
                const success = navigator.sendBeacon(this.config.sendUrl, new Blob([payload], { type: 'application/json' }));
                if (success) {
                    console.log(`Successfully sent ${logsToSend.length} logs via sendBeacon.`);
                } else {
                    console.warn('sendBeacon failed, logs might not be sent. Fallback to fetch.');
                    // 如果sendBeacon失败,可以尝试fallback到fetch
                    await this._sendLogsWithFetch(logsToSend, payload, true);
                }
            } else {
                await this._sendLogsWithFetch(logsToSend, payload, isUnload);
            }
        } catch (error) {
            console.error('Error sending logs:', error);
            // 发送失败的日志重新放回缓冲区,等待下次重试 (需要考虑重复发送和最大重试次数)
            this.buffer.push(...logsToSend);
            this._saveBufferToStorage();
        }
    }

    /**
     * 使用 fetch API 发送日志。
     * @param {Array<object>} logsToSend - 待发送的日志数组。
     * @param {string} payload - JSON 字符串格式的日志数据。
     * @param {boolean} isUnload - 是否在页面卸载时发送。
     * @private
     */
    async _sendLogsWithFetch(logsToSend, payload, isUnload) {
        try {
            const response = await fetch(this.config.sendUrl, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: payload,
                // keepalive 在页面卸载时非常重要,它允许请求在页面关闭后继续进行。
                keepalive: isUnload
            });

            if (!response.ok) {
                console.error('Failed to send logs:', response.status, response.statusText);
                // 失败的日志重新放回缓冲区
                this.buffer.push(...logsToSend);
                this._saveBufferToStorage();
            } else {
                console.log(`Successfully sent ${logsToSend.length} logs.`);
            }
        } catch (error) {
            console.error('Error during fetch operation:', error);
            this.buffer.push(...logsToSend); // 重新放回缓冲区
            this._saveBufferToStorage();
        }
    }
}

export { LogSender };

代码解析:

  • LogSender类负责管理一个内存缓冲区this.buffer
  • _loadBufferFromStorage_saveBufferToStorage方法利用localStorage实现日志的持久化,即使页面刷新或关闭,未发送的日志也能在下次加载时恢复。我们还加入了大小限制,防止localStorage被撑爆。
  • _initTimer设置一个定时器,周期性地触发日志发送。
  • _setupUnloadHandler监听beforeunloadunload事件,在页面卸载前尽力发送所有剩余日志。
  • add方法接收日志条目,为其附加UserContext提供的一切环境信息,然后加入缓冲区。当缓冲区达到batchSize时,会立即触发发送。
  • sendLogs方法是发送核心逻辑。
    • 它首先清空当前缓冲区,并将日志保存到logsToSend
    • 关键点: 在页面卸载时(isUnloadtrue),它会优先使用navigator.sendBeaconsendBeacon是一个专门为这种情况设计的API,它能保证在页面卸载后,请求仍能可靠地发送到服务器,而不会阻塞页面关闭或被浏览器取消。
    • 如果sendBeacon不可用或失败,或者不是在卸载时发送,则退而使用fetchAPI。
    • 使用fetch时,如果isUnloadtrue,务必将keepalive选项设置为true,这与sendBeacon类似,允许请求在页面关闭后继续进行。
    • 无论sendBeacon还是fetch,如果发送失败,会将日志重新放回缓冲区,等待下次重试,提高了系统的鲁棒性。

3.4 错误捕获机制 (Error Capturing)

错误是日志中最关键的部分之一。我们需要捕获各种类型的运行时错误。

// errorCapture.js

/**
 * 设置全局错误捕获机制。
 * @param {Logger} logger - 日志器实例。
 */
function setupErrorCapturing(logger) {
    /**
     * 捕获未捕获的 JavaScript 运行时错误。
     * @param {string} message - 错误消息。
     * @param {string} source - 发生错误的脚本URL。
     * @param {number} lineno - 发生错误的行号。
     * @param {number} colno - 发生错误的列号。
     * @param {Error} error - 错误对象。
     */
    window.onerror = (message, source, lineno, colno, error) => {
        // 阻止默认的浏览器错误处理,避免控制台重复打印
        // 但通常我们不阻止,让浏览器也打印出来方便开发
        // return true;

        const errorContext = {
            type: 'JavaScript Error',
            source: source,
            lineno: lineno,
            colno: colno,
            stack: error ? error.stack : 'N/A', // 尽可能获取堆栈信息
            errorName: error ? error.name : 'UnknownError'
        };
        logger.error(`Uncaught Error: ${message}`, errorContext);
    };

    /**
     * 捕获未处理的 Promise 拒绝。
     * @param {PromiseRejectionEvent} event - Promise 拒绝事件对象。
     */
    window.onunhandledrejection = (event) => {
        // 阻止默认的浏览器 Promise 拒绝处理
        event.preventDefault();

        const reason = event.reason;
        const errorContext = {
            type: 'Promise Rejection',
            message: reason && typeof reason === 'object' && reason.message ? reason.message : String(reason),
            stack: reason && typeof reason === 'object' && reason.stack ? reason.stack : 'N/A',
            errorName: reason && typeof reason === 'object' && reason.name ? reason.name : 'UnhandledPromiseRejection'
        };
        logger.error(`Unhandled Promise Rejection: ${errorContext.message}`, errorContext);
    };

    /**
     * 捕获资源加载错误 (例如图片、脚本、CSS)。
     * 注意:此方法无法捕获所有资源错误,特别是跨域脚本错误信息会非常有限。
     * 可以考虑使用 PerformanceObserver 或自定义事件来更精确地捕获。
     * @param {Event} event - 错误事件。
     */
    document.addEventListener('error', (event) => {
        const target = event.target;
        // 检查是否是资源加载错误
        if (target instanceof HTMLImageElement ||
            target instanceof HTMLLinkElement || // CSS 资源
            target instanceof HTMLScriptElement || // JS 资源
            target instanceof HTMLVideoElement ||
            target instanceof HTMLAudioElement) {

            const resourceContext = {
                type: 'Resource Error',
                tagName: target.tagName,
                srcOrHref: target.src || target.href,
                id: target.id,
                className: target.className,
                outerHTML: target.outerHTML.substring(0, 200) // 截取部分HTML
            };
            logger.warn(`Resource loading failed: ${resourceContext.srcOrHref}`, resourceContext);
        }
    }, true); // 使用捕获阶段捕获事件
}

export { setupErrorCapturing };

代码解析:

  • window.onerror:这是捕获全局 JavaScript 运行时错误的利器。当发生未被try...catch捕获的错误时,此事件会被触发。我们从中提取错误消息、源文件、行号、列号和错误对象,并记录堆栈信息。
  • window.onunhandledrejection:专门用于捕获未处理的 Promise 拒绝。event.preventDefault()可以阻止浏览器默认的控制台警告。我们同样提取拒绝原因和堆栈。
  • document.addEventListener('error', ..., true):通过在捕获阶段监听error事件,我们可以捕获到资源(如图片、脚本、样式表)加载失败时触发的错误。通过检查event.target的类型,我们可以判断是哪种资源以及其srchref。需要注意的是,对于跨域脚本,onerror事件通常只会提供"Script error."而无详细信息,这是浏览器出于安全考虑的限制。

3.5 用户行为追踪 (User Interaction Tracking)

追踪用户行为对于理解问题发生路径、分析用户习惯至关重要。

// interactionCapture.js

/**
 * 设置用户交互行为捕获机制。
 * @param {Logger} logger - 日志器实例。
 * @param {UserContext} userContext - 用户上下文实例。
 */
function setupInteractionCapturing(logger, userContext) {
    /**
     * 捕获页面导航(路由切换)事件。
     * 对于单页应用,需要重写 pushState 和 replaceState 方法来监听路由变化。
     */
    const originalPushState = history.pushState;
    history.pushState = function() {
        const result = originalPushState.apply(this, arguments);
        userContext.updateCurrentUrl(window.location.href); // 更新用户上下文的当前URL
        logger.info(`Navigation: Pushed state to ${window.location.href}`, { type: 'navigation', method: 'pushState', url: window.location.href });
        return result;
    };

    const originalReplaceState = history.replaceState;
    history.replaceState = function() {
        const result = originalReplaceState.apply(this, arguments);
        userContext.updateCurrentUrl(window.location.href);
        logger.info(`Navigation: Replaced state to ${window.location.href}`, { type: 'navigation', method: 'replaceState', url: window.location.href });
        return result;
    };

    // 监听浏览器前进/后退事件
    window.addEventListener('popstate', () => {
        userContext.updateCurrentUrl(window.location.href);
        logger.info(`Navigation: Popstate to ${window.location.href}`, { type: 'navigation', method: 'popstate', url: window.location.href });
    });

    // 页面首次加载记录
    window.addEventListener('load', () => {
        logger.info(`Page loaded: ${window.location.href}`, { type: 'pageLoad', url: window.location.href });
    });

    /**
     * 捕获用户点击事件。
     * 使用事件委托,监听 body 上的点击事件,减少事件监听器数量。
     */
    document.body.addEventListener('click', (event) => {
        const target = event.target;
        // 过滤掉非交互元素或不重要的点击
        if (target.tagName === 'BUTTON' ||
            target.tagName === 'A' ||
            target.tagName === 'INPUT' ||
            target.tagName === 'TEXTAREA' ||
            target.hasAttribute('data-log-click') || // 自定义属性标记需要追踪的点击
            target.closest('[data-log-click]')) { // 查找父级是否有标记

            const clickContext = {
                type: 'User Click',
                element: target.tagName,
                id: target.id,
                class: target.className,
                text: target.innerText ? target.innerText.trim().substring(0, 100) : '', // 截取文本内容
                path: _getElementPath(target), // 获取元素在DOM树中的路径
                screenX: event.screenX,
                screenY: event.screenY,
                clientX: event.clientX,
                clientY: event.clientY,
            };
            logger.info(`User Clicked: ${clickContext.element} ${clickContext.id || clickContext.class || clickContext.text}`, clickContext);
        }
    }, true); // 使用捕获阶段

    /**
     * 获取元素的DOM路径,用于定位。
     * @param {HTMLElement} element - 目标元素。
     * @returns {string} 元素的DOM路径。
     * @private
     */
    function _getElementPath(element) {
        if (!element || element === document.body) return 'body';
        const path = [];
        while (element && element !== document.body) {
            let selector = element.tagName.toLowerCase();
            if (element.id) {
                selector += `#${element.id}`;
            } else if (element.className) {
                selector += `.${element.className.split(' ').filter(Boolean).join('.')}`;
            }
            path.unshift(selector);
            element = element.parentElement;
        }
        return path.join(' > ');
    }

    // 更多行为追踪,例如表单提交、输入框失焦等
    // document.body.addEventListener('submit', (event) => {
    //     const form = event.target;
    //     logger.info('Form submitted', {
    //         formId: form.id,
    //         action: form.action,
    //         method: form.method
    //     });
    //     // 注意:避免直接记录表单中的敏感数据
    // }, true);
}

export { setupInteractionCapturing };

代码解析:

  • 路由追踪 (SPA): 对于单页应用,浏览器原生的hashchangepopstate事件可能不足以捕获所有路由变化。我们通过重写history.pushStatehistory.replaceState方法来拦截路由导航。每次路由变化时,更新UserContext的当前URL,并记录一条INFO级别的日志。
  • 点击事件: 我们在document.body上设置一个事件委托,监听所有点击事件。通过检查event.targettagNameidclassName或自定义的data-log-click属性,判断是否是需要追踪的交互元素。_getElementPath函数帮助我们构建一个可读性强的DOM路径,便于定位元素。
  • 页面加载: 监听window.load事件,记录页面首次加载。
  • 隐私注意: 在记录表单提交等行为时,务必注意数据脱敏,避免收集敏感信息。

3.6 性能指标收集 (Performance Monitoring)

性能是用户体验的基石。我们可以利用浏览器提供的Performance API来收集关键性能指标。

// performanceMonitoring.js

/**
 * 设置性能指标收集机制。
 * @param {Logger} logger - 日志器实例。
 */
function setupPerformanceMonitoring(logger) {
    if (!('PerformanceObserver' in window)) {
        console.warn('PerformanceObserver API not supported in this browser.');
        return;
    }

    const observer = new PerformanceObserver((list) => {
        list.getEntries().forEach((entry) => {
            const metricContext = {
                type: 'Performance Metric',
                metricName: entry.name,
                entryType: entry.entryType,
                duration: entry.duration,
                startTime: entry.startTime,
                // 更多属性根据 entry.entryType 不同而异
            };

            switch (entry.entryType) {
                case 'paint':
                    // FCP (First Contentful Paint)
                    if (entry.name === 'first-contentful-paint') {
                        logger.info(`Performance: FCP = ${entry.startTime.toFixed(2)}ms`, { ...metricContext, value: entry.startTime });
                    }
                    // FP (First Paint)
                    if (entry.name === 'first-paint') {
                        logger.debug(`Performance: FP = ${entry.startTime.toFixed(2)}ms`, { ...metricContext, value: entry.startTime });
                    }
                    break;
                case 'largest-contentful-paint':
                    // LCP (Largest Contentful Paint)
                    logger.info(`Performance: LCP = ${entry.startTime.toFixed(2)}ms`, { ...metricContext, value: entry.startTime, element: entry.element ? _getElementPath(entry.element) : 'N/A' });
                    break;
                case 'layout-shift':
                    // CLS (Cumulative Layout Shift)
                    // CLS 需要累积计算,这里只记录单次 shift,实际应用中需要额外逻辑计算总 CLS。
                    // logger.debug(`Performance: Layout Shift = ${entry.value.toFixed(4)}`, { ...metricContext, value: entry.value, hadRecentInput: entry.hadRecentInput });
                    break;
                case 'first-input':
                    // FID (First Input Delay)
                    logger.info(`Performance: FID = ${entry.duration.toFixed(2)}ms`, { ...metricContext, value: entry.duration, processingStart: entry.processingStart, inputType: entry.name });
                    break;
                case 'resource':
                    // 资源加载性能,只记录慢速资源或关键资源
                    if (entry.duration > 200) { // 超过200ms的资源
                         logger.debug(`Performance: Slow Resource: ${entry.name} = ${entry.duration.toFixed(2)}ms`, { ...metricContext, value: entry.duration, initiatorType: entry.initiatorType });
                    }
                    break;
                case 'navigation':
                    // 页面导航计时,如 DNS Lookup, TCP Handshake, Response Time等
                    // 通常在页面加载完成后一次性报告
                    logger.debug(`Performance: Navigation Timing: ${entry.name} = ${entry.duration.toFixed(2)}ms`, { ...metricContext, value: entry.duration });
                    break;
                case 'longtask':
                    // 长任务,阻塞主线程超过50ms的任务
                    if (entry.duration > 50) {
                        logger.warn(`Performance: Long Task = ${entry.duration.toFixed(2)}ms`, { ...metricContext, value: entry.duration, attribution: entry.attribution });
                    }
                    break;
                // Add more entry types as needed
            }
        });
    });

    // 观察多种性能事件类型
    observer.observe({
        entryTypes: ['paint', 'largest-contentful-paint', 'layout-shift', 'first-input', 'resource', 'navigation', 'longtask']
    });

    /**
     * 获取元素的DOM路径,用于定位。
     * @param {HTMLElement} element - 目标元素。
     * @returns {string} 元素的DOM路径。
     * @private
     */
    function _getElementPath(element) {
        if (!element || element === document.body) return 'body';
        const path = [];
        while (element && element !== document.body) {
            let selector = element.tagName.toLowerCase();
            if (element.id) {
                selector += `#${element.id}`;
            } else if (element.className) {
                selector += `.${element.className.split(' ').filter(Boolean).join('.')}`;
            }
            path.unshift(selector);
            element = element.parentElement;
        }
        return path.join(' > ');
    }
}

export { setupPerformanceMonitoring };

代码解析:

  • PerformanceObserver:这是现代浏览器提供的一个强大API,用于异步、高效地收集各种性能指标。
  • 我们创建了一个PerformanceObserver实例,并传入一个回调函数。当观察到的性能事件发生时,回调函数会被触发,并提供一个PerformanceEntryList
  • observer.observe()方法告诉浏览器我们对哪些entryTypes感兴趣,例如paint(FCP、FP)、largest-contentful-paint(LCP)、layout-shift(CLS)、first-input(FID)等。
  • 在回调函数中,我们遍历entries,根据entry.entryTypeentry.name来识别具体的性能指标,并将其记录为日志。例如:
    • FCP (First Contentful Paint): 首次内容绘制时间,表示页面内容首次呈现在屏幕上的时间。
    • LCP (Largest Contentful Paint): 最大内容绘制时间,表示页面最大视觉元素完成渲染的时间,是感知加载速度的关键指标。
    • FID (First Input Delay): 首次输入延迟,表示用户首次与页面交互(如点击按钮)到浏览器实际响应之间的时间。
    • CLS (Cumulative Layout Shift): 累积布局偏移,衡量页面在加载过程中布局变化的幅度,影响用户体验。这里仅记录单次偏移,实际系统需要累加。
    • Resource: 可以用来监控单个资源的加载耗时,发现慢速资源。
    • Longtask: 记录阻塞主线程超过50ms的任务,有助于发现性能瓶颈。
  • 同样提供了_getElementPath函数来定位LCP元素等。

3.7 系统集成与初始化

现在,我们将所有模块整合到一个入口文件中,实现系统的初始化。

// main.js

import { Logger } from './logger';
import { UserContext } from './userContext';
import { LogSender } from './logSender';
import { setupErrorCapturing } from './errorCapture';
import { setupInteractionCapturing } from './interactionCapture';
import { setupPerformanceMonitoring } from './performanceMonitoring';

/**
 * 初始化前端日志收集系统。
 * @param {object} config - 全局配置对象。
 * @returns {Logger} - 返回初始化后的Logger实例,供应用其他部分使用。
 */
function initializeFrontendLogger(config = {}) {
    // 默认配置
    const defaultConfig = {
        appVersion: '1.0.0',
        logLevel: 'INFO', // 生产环境默认为INFO,开发环境可设为DEBUG
        sendUrl: '/api/frontend-logs', // 后端接收日志的API地址
        batchSize: 5, // 每5条日志发送一次
        sendInterval: 3000, // 每3秒发送一次
        maxBufferSize: 500, // 内存和localStorage最大缓存日志条数
        storageKey: 'app_frontend_log_buffer',
        enableConsole: process.env.NODE_ENV !== 'production', // 生产环境默认不打console
    };

    const finalConfig = { ...defaultConfig, ...config };

    // 1. 初始化用户上下文
    const userContext = new UserContext();

    // 2. 初始化日志发送器
    const logSender = new LogSender(finalConfig, userContext);

    // 3. 初始化日志器
    const logger = new Logger(finalConfig, logSender);

    // 4. 设置错误捕获
    setupErrorCapturing(logger);

    // 5. 设置用户交互捕获
    setupInteractionCapturing(logger, userContext);

    // 6. 设置性能指标捕获
    setupPerformanceMonitoring(logger);

    // 暴露全局变量,方便在应用任何地方调用(可选,更推荐依赖注入)
    window.appLogger = logger;

    logger.info('Frontend logging system initialized.', { config: finalConfig });

    return logger;
}

// 在应用入口点调用初始化函数
// 例如:
// document.addEventListener('DOMContentLoaded', () => {
//     initializeFrontendLogger({
//         appVersion: '1.2.3',
//         logLevel: 'DEBUG', // 或者根据环境判断
//         sendUrl: 'https://your-backend.com/api/logs'
//     });
//
//     // 示例:在应用中记录日志
//     window.appLogger.info('Application started successfully.');
//     try {
//         // 模拟一个错误
//         // throw new Error('Something went wrong in the main logic!');
//     } catch (e) {
//         window.appLogger.error('Caught error in main app logic', { error: e.message, stack: e.stack });
//     }
//
//     // 模拟一个API调用
//     fetch('/some/data')
//         .then(res => res.json())
//         .then(data => {
//             window.appLogger.debug('Data fetched successfully', { dataLength: data.length });
//         })
//         .catch(err => {
//             window.appLogger.error('Failed to fetch data', { api: '/some/data', error: err.message });
//         });
// });

// 或者,如果你使用模块打包工具 (如 Webpack, Vite),可以直接导出并导入到你的 main.js 或 App.vue/App.jsx
export { initializeFrontendLogger };

代码解析:

  • initializeFrontendLogger函数作为系统的入口,负责创建并协调所有模块。
  • 它接收一个配置对象,并合并默认配置。
  • 按照依赖关系依次初始化UserContextLogSenderLogger
  • 然后,调用各个setup...Capturing函数,将loggeruserContext实例传递进去,注册各种事件监听器。
  • 最后,打印一条初始化成功的日志,并将logger实例暴露到window.appLogger(可选,更推荐在框架中使用依赖注入或上下文API传递)。
  • 在你的应用入口文件(例如main.js),调用initializeFrontendLogger即可启动整个日志系统。

四、高级话题与最佳实践

一个可用的日志系统已经搭建起来了,但要使其在生产环境中稳定、高效、安全地运行,我们还需要考虑更多高级话题和最佳实践。

4.1 数据隐私与安全:红线不可逾越

这是前端日志收集的重中之重,任何疏忽都可能导致严重的法律和信任问题。

  • 数据脱敏 (Data Masking/Anonymization):
    • 输入框内容: 避免直接记录用户输入到表单中的内容,尤其是密码、信用卡号、身份证号、手机号等敏感信息。如果业务上需要,只能记录脱敏后的值(如 ****1234)或哈希值。
    • URL参数: 检查URL中是否包含敏感信息(如用户ID、Token),在记录时进行清理。
    • 自定义上下文:logger.info('...', { customData: sensitiveInfo })时,开发者必须自觉过滤敏感数据。
    • 实现方式: 可以在LogSender.add()方法中增加一个前置处理器,对logEntry中的特定字段进行正则匹配或替换。
  • 最小化原则 (Data Minimization): 只收集解决问题所必需的信息。例如,如果只需要知道用户是否点击了某个按钮,就不需要记录按钮的所有CSS样式或DOM结构。
  • HTTPS传输: 日志数据包含用户行为和系统状态,必须通过HTTPS加密传输,防止中间人攻击窃取数据。
  • 用户同意 (Consent): 根据GDPR、CCPA等法规,在某些地区收集用户数据可能需要明确的用户同意。这可能意味着在日志系统初始化前,需要展示一个隐私政策弹窗,并等待用户授权。如果用户拒绝,则不应启用日志收集。
  • 本地存储安全: localStoragesessionStorage不是安全的存储介质,不应存放敏感信息。我们的日志缓冲区只存放待发送的、已脱敏的日志。

示例:日志脱敏的简单实现

可以在LogSenderadd方法中,在enrichedEntry被添加到buffer之前,增加一个脱敏处理函数:

// 在 LogSender 的 add 方法中
add(logEntry) {
    const enrichedEntry = {
        ...logEntry,
        ...this.userContext.getContext(),
    };

    const sanitizedEntry = this._sanitizeLogEntry(enrichedEntry); // 调用脱敏函数
    this.buffer.push(sanitizedEntry);

    // ... 后续逻辑
}

_sanitizeLogEntry(logEntry) {
    // 深度拷贝,避免修改原始对象
    const clonedEntry = JSON.parse(JSON.stringify(logEntry));

    // 示例1: 移除或脱敏特定上下文字段
    if (clonedEntry.context && clonedEntry.context.password) {
        clonedEntry.context.password = '********';
    }
    if (clonedEntry.context && clonedEntry.context.creditCard) {
        clonedEntry.context.creditCard = '**** **** **** ****';
    }

    // 示例2: 正则匹配脱敏消息内容
    if (clonedEntry.message) {
        clonedEntry.message = clonedEntry.message.replace(/b(d{10,12})b/g, '***********'); // 简单脱敏长数字串,如手机号
        clonedEntry.message = clonedEntry.message.replace(/email=([^&]+)/g, 'email=***'); // 脱敏URL参数中的邮箱
    }

    // 示例3: 脱敏 URL
    if (clonedEntry.currentUrl) {
        const urlObj = new URL(clonedEntry.currentUrl);
        if (urlObj.searchParams.has('token')) {
            urlObj.searchParams.set('token', '****');
        }
        if (urlObj.searchParams.has('user_id')) {
            urlObj.searchParams.set('user_id', '****');
        }
        clonedEntry.currentUrl = urlObj.toString();
    }
    // ... 更多脱敏规则

    return clonedEntry;
}

4.2 性能与资源消耗:平衡的艺术

日志收集本身也会消耗CPU、内存和网络资源,必须加以优化。

  • 异步化: 确保日志处理(缓冲、发送)是非阻塞的,不影响主线程的渲染和用户交互。我们的实现中,fetchsendBeacon都是异步的。
  • 批量发送: 减少网络请求次数,降低网络开销和服务器压力。我们的LogSender已经实现了批量发送。
  • 节流与防抖 (Throttling & Debouncing): 对于高频触发的事件(如mousemove, scroll),如果需要记录,应进行节流或防抖处理,避免产生过多日志。
  • 日志级别控制: 在生产环境中,将日志级别设置为INFOWARN以上,只记录重要的错误和信息,减少日志量。DEBUG级别只在开发环境开启。
  • 缓冲区大小限制: 限制内存和localStorage中的日志缓冲区大小,防止占用过多资源导致性能下降甚至崩溃。
  • 压缩: 在发送日志前,可以考虑对JSON payload进行Gzip压缩,进一步减少网络传输量。这需要在客户端和服务器端都支持。

4.3 鲁棒性与兼容性:面对复杂环境

前端环境复杂多变,日志系统必须足够健壮。

  • 错误处理: 日志系统自身也可能出错。例如,localStorage写入失败(配额限制)、网络请求异常等。我们的LogSender中已包含基本的重试机制(将失败日志放回缓冲区)。更完善的方案可以引入指数退避等重试策略。
  • 浏览器兼容性:
    • sendBeacon:并非所有浏览器都完全支持,需要有fetch作为降级方案。
    • PerformanceObserver:较新的API,旧版浏览器可能不支持,需要检查'PerformanceObserver' in window
    • navigator.connection:获取网络信息也并非完全兼容。
  • 代码防护: 对可能不存在的API进行检查,例如if (navigator.sendBeacon) { ... }
  • 日志系统自身的日志: 确保日志系统内部的错误能够被console.error打印出来,以便在开发和调试日志系统时发现问题。

4.4 配置与扩展性:适应需求变化

  • 模块化设计: 将不同功能(logger、sender、capturer)拆分成独立模块,便于维护、测试和扩展。
  • 可配置性: 通过配置对象控制日志级别、发送URL、批量大小等,方便在不同环境和需求下调整。
  • 插件机制: 考虑为日志系统设计插件机制,允许开发者插入自定义的日志处理器或数据转换器。例如,在发送前加密日志,或者将日志发送到多个目的地。
  • 集成框架:
    • React: 利用Error Boundaries捕获组件渲染错误,并在componentDidCatch中调用logger.error()
    • Vue: 配置Vue.config.errorHandlerVue.config.warnHandler来捕获Vue组件的错误和警告。
    • Angular: 实现自定义的ErrorHandler服务来统一处理应用中的错误。

4.5 后端日志处理与可视化:完整链路

前端日志收集只是整个链路的第一步。

  • 后端API: 需提供一个稳定的HTTP POST接口接收日志数据。
  • 日志存储: 将接收到的日志存储到专业日志管理系统(如Elasticsearch、Splunk、Loki等)或时序数据库中。
  • 日志分析与查询: 利用这些系统的强大查询功能,快速定位错误、分析用户行为路径、筛选性能瓶颈。
  • 告警系统: 基于日志数据设置告警规则,例如,当错误率超过阈值、页面加载时间过长时,自动通知开发团队。
  • 可视化仪表盘: 通过Kibana、Grafana等工具,将日志数据转化为直观的图表和仪表盘,实时监控应用健康状况和用户体验。

表格:sendBeacon vs fetch (with keepalive)

特性 navigator.sendBeacon() fetch(..., { keepalive: true })
可靠性 专门设计用于页面卸载时发送数据,浏览器会尽力发送。 同样在页面卸载时提供高可靠性,但可能不如sendBeacon稳定(取决于浏览器实现)。
阻塞性 非阻塞,不会延迟页面卸载。 非阻塞,通常不会延迟页面卸载。
请求类型 只能发送POST请求,数据类型有限(Blob, ArrayBufferView, DOMString, FormData)。 支持所有HTTP方法,数据类型更灵活。
响应处理 不提供响应回调,无法处理服务器返回。 提供Promise接口,可处理服务器响应(例如重试逻辑)。
请求头 浏览器自动设置Content-Type,无法自定义更多请求头。 可自定义所有请求头。
浏览器支持 较好,但比fetch晚。 普遍支持。
适用场景 页面卸载时发送少量、不需要响应反馈的数据(如统计、日志)。 页面卸载时发送数据且需要处理响应,或需要自定义请求头、更多数据类型。

结论: 在页面卸载场景下,sendBeacon是首选,其设计目标就是为了解决这个问题。如果需要更复杂的请求定制或响应处理,fetch配合keepalive: true是一个可靠的替代方案。我们的系统优先使用sendBeacon,并提供fetch作为后备。


五、结语

至此,我们已经全面而深入地探讨了前端日志收集系统的构建。从理解其重要性、设计核心概念,到亲手实现基础日志器、错误捕获、行为追踪、性能监控,再到讨论数据隐私、性能优化和系统鲁棒性,我们覆盖了从理论到实践的每一个关键环节。

一个健壮的前端日志收集系统,不仅仅是代码库中的一个模块,更是我们理解用户、发现问题、提升产品质量的强大武器。它将帮助我们从被动响应用户反馈,走向主动发现和解决问题的境界,让我们的应用在复杂的线上环境中更加透明、可控。

技术永无止境,前端日志系统也需要持续的迭代和优化。希望今天的分享能为大家提供一个坚实的基础,启发大家根据自身业务场景,构建出更强大、更智能的前端监控体系。

感谢大家的聆听!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注